Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ac1bdcfd5 | ||
|
|
bc4730eb30 | ||
|
|
83ae3a08ff | ||
|
|
7bbc47616c | ||
|
|
43200d7dfe | ||
|
|
cef826e46e | ||
|
|
8946915bfe | ||
|
|
89d64418de | ||
|
|
2ae3e3c0e6 | ||
|
|
a636ecd991 | ||
|
|
42fe5b9304 | ||
|
|
40cc6b8c66 | ||
|
|
d39d344be2 | ||
|
|
7f4cf860e2 | ||
|
|
9e0f7cb773 | ||
|
|
1245ed9f5f | ||
|
|
8e64fa77b7 | ||
|
|
b68c69ebe8 | ||
|
|
971f4a92c1 | ||
|
|
d0c38378b6 | ||
|
|
6d15ad31f2 | ||
|
|
94f1634fc2 | ||
|
|
f8c1892e99 | ||
|
|
bfa237396c | ||
|
|
0b9338fcd6 | ||
|
|
2d5ed4cc5d | ||
|
|
a6fc206560 | ||
|
|
69bec6f9b7 | ||
|
|
6ac8ad74a0 | ||
|
|
29e0a969f0 | ||
|
|
1deaa1cffc | ||
|
|
acdce9d569 | ||
|
|
9993357728 | ||
|
|
e0bba3c4bf | ||
|
|
a705f6e3e0 | ||
|
|
03161dbeaa | ||
|
|
8b12366183 | ||
|
|
e5ff977ca4 | ||
|
|
d5febe1cbe | ||
|
|
03dd469c27 | ||
|
|
4067e0f0ce | ||
|
|
71a07e0697 | ||
|
|
71386055b0 | ||
|
|
87226b1f36 | ||
|
|
76f96f9200 | ||
|
|
081401b9ef | ||
|
|
b2b7f85aba | ||
|
|
af7650c4d4 | ||
|
|
b9cf812f8e | ||
|
|
1e5b8fea62 | ||
|
|
b47b0917ed | ||
|
|
d4c6e49b64 | ||
|
|
ccc0ebf795 | ||
|
|
e4246eb60d | ||
|
|
7cf45e8243 | ||
|
|
cc2a29d683 | ||
|
|
c103a8e6dc |
+17
@@ -0,0 +1,17 @@
|
||||
# Site-specific credentials and deployment overrides.
|
||||
/cms/config.local.php
|
||||
|
||||
# Runtime CMS state; retain only the web-access guards committed below.
|
||||
/cms/data/*
|
||||
!/cms/data/.htaccess
|
||||
!/cms/data/index.php
|
||||
/cms/logs/*
|
||||
!/cms/logs/.htaccess
|
||||
!/cms/logs/index.php
|
||||
|
||||
# Uploaded content belongs to the deployed site rather than source control.
|
||||
/uploads/*
|
||||
!/uploads/.htaccess
|
||||
!/uploads/index.php
|
||||
|
||||
# Generated security-audit reports are retained deliberately and should remain visible to Git.
|
||||
@@ -1,53 +1,405 @@
|
||||
# NeoCMS
|
||||
|
||||
## Version 2.0
|
||||
## Version 3.0
|
||||
|
||||
Welcome to NeoCMS, the super lightweight, database-free content management system! Built with PHP and JavaScript, NeoCMS
|
||||
is designed to be fast and easy to use. No complicated setup required—just drop it into your static website, and you’re
|
||||
good to go.
|
||||
Welcome to NeoCMS, the lightweight, database-free content management system. Built with PHP and JavaScript, NeoCMS is
|
||||
designed to add controlled visual editing to an existing static website without changing how public pages are served.
|
||||
|
||||
Version 2.0 is a complete overhaul to support the latest versions of jQuery and TinyMCE (GPL). We’ve made things even
|
||||
smoother for your content editing experience.
|
||||
Version 3.0 adds filesystem-backed drafts, revision history, role-based permissions, scheduled publishing, page and media
|
||||
management, SEO controls, shared content, navigation menus, responsive previews, accessibility checks, and an operational
|
||||
dashboard. Public content remains ordinary HTML; the database server may continue its well-earned rest.
|
||||
|
||||
Further technical documentation:
|
||||
|
||||
* [Architecture](docs/ARCHITECTURE.md)
|
||||
* [Development and testing](docs/DEVELOPMENT.md)
|
||||
|
||||
## Features
|
||||
|
||||
* No Database Required: NeoCMS works without a database, so you don’t have to worry about setup hassles or performance
|
||||
slowdowns.
|
||||
* Easy Content Editing: With our intuitive WYSIWYG editor, anyone can update content without writing a single line of
|
||||
code. Adding the additional class *neo-dupe* will allow an element and it's children to be cloned.
|
||||
* Easy Content Editing: The TinyMCE visual editor updates marked content regions without requiring authors to edit HTML.
|
||||
Adding `neo-dupe` allows an element and its children to be cloned or removed.
|
||||
* Seamless Integration: Simply add the CMS to any existing static website. Just assign a special class to the elements
|
||||
you want to make editable, and you’re all set.
|
||||
* Consistent Layouts: NeoCMS supports page templates to keep your layouts neat and uniform across all pages.
|
||||
* Drafts and Publishing: Clicking Save in the editor keeps the change as a private draft without touching the public page.
|
||||
Editors can then publish immediately or schedule a publication.
|
||||
* Revision History: Every changed or deleted page receives a timestamped filesystem snapshot that editors can restore, and
|
||||
deleted pages can be recovered from the Dashboard.
|
||||
* Page Management: Search, duplicate, rename, and delete editable HTML pages, including pages in subdirectories. The New page section of the Pages dialogue is a
|
||||
three-step wizard (template, page name, navigation group); the new page stays a private draft until it is published. Any page can
|
||||
be offered as a template with Create Template. Administrators can Lock a page to stop editors publishing, saving a draft, or
|
||||
scheduling changes to it; administrators are never restricted by a lock.
|
||||
* Media Library: Upload images, documents, video, and audio; they are sorted into Imagery, Documents, Video, and Audio by file
|
||||
extension. Reuse them from the editor's Media library button, maintain alternative text or link text, see usage counts, and
|
||||
remove files. Executables and files containing code are refused.
|
||||
* SEO Settings: Edit the page title, description, canonical URL, Open Graph image, and robots indexing setting.
|
||||
* Shared Content: Mark content with `data-neo-shared="name"` and update matching regions across the site.
|
||||
* Navigation Menus: The site scan finds each page's primary and secondary navigation and pre-fills the menus. Edit, rename, or
|
||||
delete a menu and the change is applied to every page that uses it. Menus are created by the web developer's markup, not by the
|
||||
CMS.
|
||||
* Responsive Preview: Preview the current page at desktop, tablet, and mobile widths using the device icons in the top bar.
|
||||
* Accessibility Checks: Check common authoring problems such as missing alt text, empty links, heading jumps, missing labels,
|
||||
and absent page metadata before publishing.
|
||||
* Site Scan: Analyse every managed page and the media library, then make pages editable in one step. The scan
|
||||
finds content areas, images, navigation, broken local references, and missing SEO tags, and adds the markers NeoCMS needs.
|
||||
* Users and Roles: Everyone can update their display name, email address, and password in Tools > Users. Administrators can add,
|
||||
edit, invite, block, and delete accounts. Two roles, Editor and Administrator, are described on that screen.
|
||||
* Activity: The dashboard displays drafts, schedules, deleted pages, system problems, and recent activity.
|
||||
|
||||
## Installation Guide
|
||||
|
||||
Ready to get started? Just follow these simple steps:
|
||||
To install NeoCMS:
|
||||
|
||||
* Drop the CMS folder into your static website.
|
||||
* Add the uploads folder to your site as well.
|
||||
* Open /cms/config.php and edit the credentials for any users you want to set up.
|
||||
* Update the div elements you want to make editable by adding the class editable.
|
||||
* Visit /cms/, log in, and start editing!
|
||||
* Place the `cms/` directory in the document root of the static website.
|
||||
* Place the `uploads/` directory in the same document root.
|
||||
* Copy `cms/config.local.php.example` to `cms/config.local.php`, add password hashes, and assign roles.
|
||||
* Update the elements you want to make editable by adding the configured editable class. It is `editable` by default.
|
||||
* Confirm that PHP may write to the managed HTML files, `cms/data/`, `cms/logs/`, and `uploads/`.
|
||||
* **Live sites:** create a folder outside the web root (for example `/var/neocms-data`), make it writable by PHP, and set
|
||||
`'dataDirectory' => '/var/neocms-data'` in `cms/config.local.php`. It must be an absolute path. Without this, accounts, drafts,
|
||||
and revisions live in `cms/data/`, which is protected only by the supplied `.htaccess`. Administrators see a Dashboard
|
||||
recommendation until the data folder is outside the web root. Audit logs remain in `cms/logs/`, also protected by `.htaccess`.
|
||||
* Visit `/cms/`, log in, and begin editing.
|
||||
|
||||
The site may also live in a subfolder (for example `http://localhost/mysite/`): the URL prefix is detected automatically. See
|
||||
[Subfolder installs](#subfolder-installs).
|
||||
|
||||
That’s it! You’re up and running.
|
||||
|
||||
## Configuration
|
||||
|
||||
Tracked defaults live in `cms/config.php`. Deployment credentials and overrides belong in the Git-ignored
|
||||
`cms/config.local.php`, which must return an array. The supplied Apache rules deny direct access to both files.
|
||||
|
||||
| Setting | Type | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `authentication` | array | Maps the main administrator usernames to `password_hash()` values; plaintext credentials are rejected. These accounts are managed only here. |
|
||||
| `roles` | array | Maps those usernames to `editor` or `administrator` (`publisher` is accepted as an alias of `editor`). |
|
||||
| `security` | array | Configures session expiry, login throttling, request limits, storage quotas, and audit-log retention. |
|
||||
| `uploads` | array | Configures the per-file size limit (all media types), image dimension, file-count, and aggregate-storage limits. |
|
||||
| `audit` | boolean | Enables daily activity logs under `cms/logs/`. |
|
||||
| `skipWelcomePage` | boolean | Opens the public root instead of the built-in guide after login. |
|
||||
| `showFullUrl` | boolean | Displays the current page path in the toolbar. |
|
||||
| `editableClass` | string | Selects the single CSS class used to identify editable regions. |
|
||||
|
||||
`dataDirectory` is the absolute path of the folder holding accounts (`users.json`), drafts, revisions, schedules, and menus. It
|
||||
defaults to `cms/data/`; on a live site, point it at a folder outside the web root (see the Installation Guide) so those files
|
||||
can never be served. The folder is created on first use if PHP may create it, and the CLI scheduler uses the same setting.
|
||||
|
||||
`basePath` (URL prefix of the site root, such as `/mysite`) and `siteRoot` (the folder containing `cms/`) are detected
|
||||
automatically and only need setting behind a path-rewriting proxy.
|
||||
|
||||
### Editable Class
|
||||
|
||||
The class used to identify editable regions is configurable:
|
||||
|
||||
```php
|
||||
'editableClass' => 'editable',
|
||||
```
|
||||
|
||||
Set it to any single valid CSS class name without a leading dot, for example:
|
||||
|
||||
```php
|
||||
'editableClass' => 'cms-content',
|
||||
```
|
||||
|
||||
Your page markup must then use that class:
|
||||
|
||||
```html
|
||||
<section class="cms-content">This region can be edited.</section>
|
||||
```
|
||||
|
||||
Invalid configured values fall back to `editable`. The `neo-dupe` class remains fixed because it marks repeatable blocks,
|
||||
not general editable content. When overriding `editableClass`, update the classes in `cms/templates/` as well so newly
|
||||
created pages are immediately discoverable by the page picker.
|
||||
|
||||
### Users and Roles
|
||||
|
||||
There are two kinds of account:
|
||||
|
||||
* **Config accounts** are the main administrators. Generate a hash with `password_hash()` and store it under `authentication` in
|
||||
`cms/config.local.php`, with a role under `roles`. They can be changed only in that file (in Tools > Users they are listed as
|
||||
"Config"; only their display name and email can be set from the CMS).
|
||||
* **CMS accounts** are created in **Tools > Users** and stored in `cms/data/users.json`.
|
||||
|
||||
```php
|
||||
'authentication' => [
|
||||
'admin' => '$2y$10$...',
|
||||
],
|
||||
'roles' => [
|
||||
'admin' => 'administrator',
|
||||
],
|
||||
```
|
||||
|
||||
There are two roles:
|
||||
|
||||
| Role | Can do |
|
||||
| --- | --- |
|
||||
| `editor` | Edit content, save drafts, upload images, publish or schedule pages, and restore revisions or deleted pages. Cannot change the site structure or manage users. |
|
||||
| `administrator` | Everything an editor can, plus creating new pages, duplicate, rename, delete and lock pages, templates, navigation menus, shared content, site scan, deleting media, and managing users. |
|
||||
|
||||
The former `publisher` role is now part of `editor`; existing `publisher` assignments are treated as `editor`. Users without an
|
||||
explicit valid role receive `editor`, the least powerful role.
|
||||
|
||||
#### The Users screen
|
||||
|
||||
* **My account** (every user): see your login name (set by an administrator), change the display name, the email address (needs the
|
||||
current password), and the password (current, new, confirmation; CMS accounts only). You sign in with your login name and
|
||||
password; the email address is contact information only.
|
||||
* **Roles**: a description of each role, with your own marked.
|
||||
* **User accounts** (administrators): add an account with a password, edit an account (name, email, role, optional password
|
||||
reset), invite someone, block or unblock, or delete. Add, edit, invite, block, and delete ask for the administrator's own
|
||||
password. You cannot block, delete, or demote yourself, and config accounts cannot be changed here.
|
||||
* **Invitations** produce a one-time link that is shown once and expires after 7 days. The invitee opens it and chooses their own
|
||||
password. No email is sent by the CMS.
|
||||
|
||||
#### Account protection
|
||||
|
||||
Passwords are 12 to 72 characters and are stored only as hashes. Invite tokens are stored only as hashes and work once. Secrets
|
||||
never appear in API responses or the activity log. Repeated wrong passwords are rate limited, every account event is recorded in
|
||||
the activity feed and audit log, and blocking, deleting, changing a role, or changing a password affects that user's sessions on
|
||||
their next request. `data/.htaccess` denies web access to `users.json`.
|
||||
|
||||
Sessions expire after 30 minutes of inactivity and 12 hours in total by default.
|
||||
|
||||
When HTTPS terminates at a trusted reverse proxy, set `'cookieSecure' => true` beneath `security` in the local override.
|
||||
This keeps the session cookie marked Secure even when PHP receives the proxy's internal HTTP request.
|
||||
|
||||
Audit files rotate at 10 MiB and are retained for 90 days by default. Override `auditMaxFileBytes` and
|
||||
`auditRetentionDays` beneath `security` where local retention obligations differ.
|
||||
|
||||
### Password Hash Example
|
||||
|
||||
Generate a hash on a trusted command line, then place only the resulting hash in `cms/config.local.php`:
|
||||
|
||||
```bash
|
||||
php -r 'echo password_hash(trim(fgets(STDIN)), PASSWORD_DEFAULT), PHP_EOL;'
|
||||
```
|
||||
|
||||
There is deliberately no enabled default account.
|
||||
|
||||
## Content Features
|
||||
|
||||
### Repeatable Blocks
|
||||
|
||||
Add `neo-dupe` alongside the configured editable class. The editor provides clone-before, clone-after, and delete controls:
|
||||
|
||||
```html
|
||||
<article class="editable neo-dupe">Repeatable content</article>
|
||||
```
|
||||
|
||||
### Shared Content
|
||||
|
||||
Use `data-neo-shared` to identify a global region:
|
||||
|
||||
```html
|
||||
<footer class="editable" data-neo-shared="site-footer">Shared footer content</footer>
|
||||
```
|
||||
|
||||
The Shared Content tool updates every HTML page containing the same key. Publishing a page also captures the current value
|
||||
of its shared regions.
|
||||
|
||||
### Navigation Menus
|
||||
|
||||
Menus come from your site's own navigation. The **site scan** finds each page's `<nav>`: a header, banner, or main navigation is
|
||||
treated as `primary`, a footer or legal navigation as `secondary`, and the block is marked in place with
|
||||
`data-neo-menu="name"`. The first page that contains a menu supplies its items, and a saved menu is never overwritten.
|
||||
|
||||
**Tools > Navigation Menus** (main toolbar) lists the menus. Choose **Edit** to change a menu's display name or items, one per
|
||||
line:
|
||||
|
||||
```text
|
||||
Home | /
|
||||
About | /about.html
|
||||
Team | /team.html | About
|
||||
```
|
||||
|
||||
The optional third value is the label of the parent item. Saving rewrites only the list inside each marked `<nav>` on every
|
||||
page (other bytes, the `<nav>` attributes, and `aria-current="page"` for the current page are handled), and skips pages that
|
||||
would not change. **Delete** removes a menu from the CMS; pages keep their current navigation. **Scan site for new navigation**
|
||||
finds navigation blocks a developer has added since the last scan.
|
||||
|
||||
The CMS does not create menus: new menus are the web developer's markup, discovered by the scan. The menu key cannot be renamed,
|
||||
but the display name can.
|
||||
|
||||
### Media Library
|
||||
|
||||
**Media** uploads and lists files in four categories, decided by the file extension:
|
||||
|
||||
| Category | Accepted extensions |
|
||||
| --- | --- |
|
||||
| Imagery | jpg, jpeg, png, gif, webp |
|
||||
| Documents | pdf, docx, xlsx, pptx, odt, ods, odp, txt, csv |
|
||||
| Video | mp4, m4v, webm, mov, ogv |
|
||||
| Audio | mp3, wav, ogg, oga, m4a, aac, flac |
|
||||
|
||||
Anything not in this list is refused: executables (`.exe`, `.dll`, `.bat`, `.msi`, ...), scripts and server code (`.php`, `.js`, `.sh`, ...),
|
||||
HTML, SVG, XML, archives, the older `.doc`/`.xls`/`.ppt` formats, and macro-enabled Office files. Every upload is also checked
|
||||
by content, not just name: the file type must match its extension; files that start like an executable or contain `<?php`,
|
||||
`<?=`, `<script`, or `<%` anywhere are refused (so a code payload hidden inside an image is caught); PDFs containing
|
||||
JavaScript, launch actions, or embedded files are refused; and Office/OpenDocument files are refused if they contain macros,
|
||||
embedded objects, scripts, or executables. Names such as `report.exe.pdf` are refused too. These checks are best-effort hardening,
|
||||
not a malware scanner, so keep server-side virus scanning where policy requires it.
|
||||
|
||||
Uploaded files are saved under `uploads/` with a generated name (`price-list-3f2a9c1d.pdf`); the original name is kept as the
|
||||
default link text. Every file is limited by `uploads.maxFileBytes` (10 MiB by default), and PHP's `upload_max_filesize` and
|
||||
`post_max_size` must allow at least that. In the editor, use the **Media library** button to insert an image, a document link, a
|
||||
video player, or an audio player at the cursor. Documents are served as downloads. `uploads/.htaccess` denies executable and markup
|
||||
extensions; for Nginx use the rule shown under Web Server Security.
|
||||
|
||||
### Site Scan
|
||||
|
||||
Administrators can open **Tools > Site scan** to analyse the whole site: page and folder counts, pages that can be made
|
||||
editable, images without alternative text, broken local image/CSS/script references, uploaded files that no page uses, and
|
||||
missing title, description, and Open Graph tags. Tick the pages and the kinds of change to make, then apply:
|
||||
|
||||
* **Content areas**: adds the editable class to `<main>` (or `role="main"`), otherwise to top-level `<article>` or
|
||||
text-bearing `<section>` elements. Headers, navigation, footers, asides, and forms are skipped, and `<body>` is never
|
||||
tagged. Pages that already contain an editable region are left alone; pages with no safe container are reported for manual
|
||||
tagging.
|
||||
* **Images**: adds `data-neo-image` to images outside editable regions. In the editor, clicking one opens a dialogue to pick
|
||||
a Media Library image or change its address and alternative text.
|
||||
* **Navigation**: marks each `<nav>` (primary/secondary) with `data-neo-menu` and pre-fills Navigation Menus from it.
|
||||
* **SEO tags**: inserts only the missing title, description, `og:title`, `og:description`, and `og:image` tags, with values
|
||||
taken from the page. Existing tags are never changed, and canonical and robots values are reported but not invented.
|
||||
|
||||
The scan edits page source directly rather than re-parsing it, so nothing else in a file changes, and a revision of every
|
||||
changed page is kept in Revision history.
|
||||
|
||||
### Drafts and Revisions
|
||||
|
||||
Drafts do not alter public HTML. When opening a page with a saved draft, NeoCMS offers to load it. Publishing creates a
|
||||
revision of the previous public file first. Deleting a page also retains its last revision, and **Dashboard > Deleted pages**
|
||||
lists pages whose newest revision is "Before delete" and whose file is gone, with a **Recover** button that recreates the page
|
||||
from that snapshot. Revisions are pruned by the configured retention limits.
|
||||
|
||||
### New Pages and Templates
|
||||
|
||||
The **New page** section (administrators), under the page list in **Pages**, is a three-step wizard: choose a template, give the page a name, then choose a navigation group
|
||||
(or none). The filename is generated from the name (`About Us` becomes `about-us.html`, numbered if taken). The page is saved
|
||||
only as a private draft: no file exists, so it is not visible on the site, until it is first published (Publish Now or a
|
||||
schedule). Publishing creates the file and adds a link to the chosen menu on every page. Unpublished pages appear in Pages as
|
||||
"New - unpublished" and can be discarded.
|
||||
|
||||
Templates are the files in `cms/templates/` plus any page you choose **Create Template** for in the Pages list. A template page
|
||||
is used live (later edits to it show in new pages), and the **Delete** action in the template list deletes a template file or
|
||||
stops using a page as a template (the page is kept).
|
||||
|
||||
### Locking Pages
|
||||
|
||||
Administrators can **Lock** a page from the Pages list to stop editors publishing, saving a draft, or scheduling a change to
|
||||
it; a **Locked** badge shows next to the page name for everyone. Administrators are never restricted by a lock, and locking
|
||||
is independent of Create Template, Duplicate, Rename, and Delete, which were already administrator-only. A lock follows a
|
||||
page when it is renamed, is cleared when the page is deleted, and is never copied onto a duplicate.
|
||||
|
||||
### Subfolder Installs
|
||||
|
||||
The site can live in a subfolder such as `http://localhost/neocms/`. The URL prefix (`basePath`) and the folder that contains
|
||||
`cms/` (`siteRoot`) are detected automatically, page links stored by menus are prefixed accordingly, and the session cookie path
|
||||
follows the CMS. Root installs are unchanged.
|
||||
|
||||
NeoCMS stores metadata, drafts, scheduled content, and revisions under `cms/data/`. Back up this directory together with
|
||||
the website. The supplied `.htaccess` denies direct HTTP access on Apache; other web servers should deny `/cms/data/` and
|
||||
`/cms/logs/` explicitly. Private directories and files are created with owner-only permissions where the operating system
|
||||
permits it. Configurable quotas bound drafts, schedules, revisions, and uploaded media to reduce disk-exhaustion risk.
|
||||
|
||||
### Scheduled Publishing
|
||||
|
||||
Due schedules are processed whenever the CMS API is used. For publication without an active CMS user, run the CLI worker
|
||||
once per minute with cron:
|
||||
|
||||
```cron
|
||||
* * * * * /usr/bin/php /absolute/path/to/site/cms/publish_scheduled.php >/dev/null 2>&1
|
||||
```
|
||||
|
||||
The server timezone controls PHP date handling; the browser converts the selected local time to an absolute timestamp.
|
||||
|
||||
## Administration Workflow
|
||||
|
||||
1. Open a page from **Pages** (or create one in the **New page** section under the list).
|
||||
2. Select a region carrying the configured editable class and edit it in TinyMCE. Clicking an image opens the image dialogue.
|
||||
3. In the editor, click **Save Draft** to keep your change private, **Publish Now** to replace the public HTML after
|
||||
creating a revision, or **Schedule Publish** when publication should occur later. **Cancel** discards unapplied edits.
|
||||
4. Check revisions, accessibility findings, SEO metadata, shared content, and menus through the relevant toolbar dialogues.
|
||||
|
||||
The top bar shows the current page path on the left; your name and Log out are at the top right, with the toolbar buttons
|
||||
(Dashboard, Pages, Media, SEO, Navigation Menus, Tools) and the desktop/tablet/mobile preview icons beneath. Tools holds
|
||||
Revision history, Accessibility check, Shared content, Site scan, and Users. Dialogue buttons share one style: small
|
||||
secondary buttons for row actions, larger buttons for actions (Save, Next, Apply), and blue for the primary action.
|
||||
|
||||
Save Draft in the content editor (and Apply in the image and SEO dialogues) stores the change as a private draft. It never writes
|
||||
the public file, which only changes on Publish. This is intentional and prevents an innocent wording change from making an
|
||||
unscheduled public debut. The page header shows **Draft:** once a draft is saved, and reopening the page offers to load it.
|
||||
|
||||
## Page Markup Recommendations
|
||||
|
||||
For the best SEO and accessibility results, each page should include one `<h1>`, a non-empty `<title>`, a meta description,
|
||||
alternative text on every image, descriptive link text, and labels associated with form fields. The checker highlights
|
||||
common authoring issues but is not a complete accessibility audit.
|
||||
|
||||
## Requirements
|
||||
|
||||
* PHP 8 running on Linux
|
||||
* PHP DOM and fileinfo extensions (the fileinfo extension is used to check uploads; the zip extension is not needed)
|
||||
* PHP sessions enabled
|
||||
* Write access to `cms/data/`, `cms/logs/`, `uploads/`, and managed HTML pages
|
||||
* A modern browser with JavaScript enabled
|
||||
|
||||
## Known Bugs & Issues
|
||||
The administration shell loads jQuery 4.0.0 and jQuery UI 1.14.2 from the official jQuery CDN with Subresource Integrity.
|
||||
TinyMCE 8.6.0 is bundled locally. Installations that must operate without internet access should self-host the two jQuery
|
||||
assets and update both `cms/index.php` and the administration Content Security Policy.
|
||||
|
||||
* File Uploads (non-image): Currently, file uploads are not fully implemented. We're working on it!
|
||||
* Testing: The system is functional, but it hasn’t been fully tested in all scenarios.
|
||||
## Web Server Security
|
||||
|
||||
## License
|
||||
Apache users should allow the supplied `.htaccess` rules. For Nginx, add equivalent rules such as:
|
||||
|
||||
NeoCMS is open-source software licensed under the GNU General Public License v3.0 (GPLv3). You are free to use, modify,
|
||||
and distribute this software under the terms of this license.
|
||||
```nginx
|
||||
location ^~ /cms/data/ { deny all; }
|
||||
location ^~ /cms/logs/ { deny all; }
|
||||
location ~ ^/cms/(config(?:\.local)?\.php|config\.local\.php\.example|publish_scheduled\.php)$ { deny all; }
|
||||
location ~ ^/uploads/.*\.(php[0-9]?|phtml|phar|cgi|pl|py|sh|exe|dll|bat|cmd|com|msi|asp|aspx|jsp|jspx|html?|xhtml|shtml|svg|xml|js|jar|swf)$ { deny all; }
|
||||
```
|
||||
|
||||
For more information, see the full GPLv3 License found in LICENSE.
|
||||
Also disable directory listings, prevent PHP source download through server misconfiguration, serve the CMS only over
|
||||
HTTPS, and back up both public HTML and `cms/data/`. NeoCMS sends CSP, anti-framing, no-sniffing, referrer, permissions,
|
||||
cross-origin isolation, no-store, and HTTPS HSTS headers where appropriate.
|
||||
|
||||
Set request-body limits in the web server and PHP as well as in NeoCMS. For the default upload and API limits, a 12 MiB
|
||||
server limit leaves room for form overhead without letting oversized requests make themselves at home. For example:
|
||||
|
||||
```nginx
|
||||
client_max_body_size 12m;
|
||||
client_body_timeout 15s;
|
||||
```
|
||||
|
||||
Match `post_max_size` and `upload_max_filesize` to your configured `security.maxRequestBytes` and `uploads.maxFileBytes`.
|
||||
|
||||
The page preview iframe is sandboxed with `allow-same-origin` but without `allow-scripts`. This preserves DOM editing while
|
||||
preventing public page scripts from reaching the parent administration session.
|
||||
|
||||
## Testing
|
||||
|
||||
Run the dependency-free integration suite with:
|
||||
|
||||
```bash
|
||||
php tests/run.php
|
||||
```
|
||||
|
||||
It exercises authentication hardening, session revocation, login throttling, iframe sandboxing, path containment, content
|
||||
limits, configurable editable regions, drafts, publication, revisions, page operations, new-page privacy and templates,
|
||||
shared content, menu detection and propagation, user accounts (roles, invitations, blocking, password confirmation, config
|
||||
accounts), and scheduled publishing in temporary directories. See [Development and testing](docs/DEVELOPMENT.md) for the complete check
|
||||
list used by this repository.
|
||||
|
||||
## Licence
|
||||
|
||||
NeoCMS is open-source software licensed under the GNU General Public Licence v3.0 (GPLv3). You are free to use, modify,
|
||||
and distribute this software under the terms of this licence.
|
||||
|
||||
For the complete terms, see [LICENSE](LICENSE).
|
||||
|
||||
## Found a Bug?
|
||||
|
||||
If you run into any issues or have feature requests, please submit them on our GitHub issues page. Your feedback helps
|
||||
us improve NeoCMS!
|
||||
Report defects and feature requests through the project's GitHub issue tracker.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Prevent directory listings and direct access to configuration or CLI-only entry points.
|
||||
Options -Indexes
|
||||
|
||||
<FilesMatch "^(config(?:\.local)?\.php|config\.local\.php\.example|publish_scheduled\.php)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set Referrer-Policy "no-referrer"
|
||||
</IfModule>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
/** Shared entry-point setup: loads configuration into $config and registers the NeoCMS class autoloader. */
|
||||
|
||||
// Keep stack traces and filesystem paths out of responses; every engine warning still reaches PHP's own error log.
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('display_startup_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
// URL prefix of the site root: "/neocms" for http://localhost/neocms/, "" for a site at the domain root.
|
||||
// Derived from this script's URL unless config.local.php sets 'basePath' (e.g. behind a path-rewriting proxy).
|
||||
$config['basePath'] = rtrim((string) ($config['basePath'] ?? (preg_match('#^(.*)/cms/#', $_SERVER['SCRIPT_NAME'] ?? '', $neoBase) ? $neoBase[1] : '')), '/');
|
||||
// The site root on disk is always the folder containing cms/, whatever DOCUMENT_ROOT says.
|
||||
$config['siteRoot'] ??= dirname(__DIR__);
|
||||
$config['security']['cookiePath'] = $config['basePath'] . '/cms';
|
||||
|
||||
spl_autoload_register(function ($class) {
|
||||
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
|
||||
require_once __DIR__ . "/src/{$classPath}.php";
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copy this file to config.local.php and replace the example values.
|
||||
* config.local.php is ignored by Git and denied by the supplied Apache rules.
|
||||
*/
|
||||
return [
|
||||
'authentication' => [
|
||||
'admin' => '$2y$10$replace-with-a-real-password-hash',
|
||||
],
|
||||
'roles' => [
|
||||
'admin' => 'administrator',
|
||||
],
|
||||
// Live sites: store CMS data (accounts, drafts, revisions) OUTSIDE the web root, as an absolute path PHP can write to,
|
||||
// for example '/var/neocms-data'. The default cms/data/ is protected only by .htaccess.
|
||||
// 'dataDirectory' => '/var/neocms-data',
|
||||
// Set this to true when HTTPS is terminated by a trusted reverse proxy.
|
||||
'security' => [
|
||||
'cookieSecure' => true,
|
||||
// Uncomment only where local audit-retention policy differs from the secure defaults.
|
||||
// 'auditMaxFileBytes' => 10 * 1024 * 1024,
|
||||
// 'auditRetentionDays' => 90,
|
||||
],
|
||||
];
|
||||
+58
-9
@@ -1,18 +1,67 @@
|
||||
<?php
|
||||
/*
|
||||
* Authentication stores the usernames and passwords for people to be able to use the CMS.
|
||||
* This will be used as well to write to the audit log once it has been implemented in code.
|
||||
/**
|
||||
* NeoCMS site configuration.
|
||||
*
|
||||
* Keep credentials in the ignored config.local.php override rather than this tracked defaults file.
|
||||
* No account is enabled by default; configure a password hash and role before exposing /cms/.
|
||||
*/
|
||||
|
||||
$config = [
|
||||
'authentication' => [
|
||||
// Add more users as needed - its 'username'=>'password' if you hadn't have guessed
|
||||
'admin' => 'password123',
|
||||
// 'admin' => '$2y$10$replace-with-a-real-password-hash',
|
||||
],
|
||||
// Enable the auditing system
|
||||
// Accounts here are the main administrators and are managed only in this file. Other accounts are created in the CMS (Tools > Users).
|
||||
// Roles: editor (edit, publish, schedule) or administrator (everything, including users). The old 'publisher' role counts as editor.
|
||||
'roles' => [
|
||||
// 'admin' => 'administrator',
|
||||
],
|
||||
// Session expiry and login throttling. Set cookieSecure to true behind a TLS reverse proxy.
|
||||
'security' => [
|
||||
'idleTimeout' => 1800,
|
||||
'absoluteTimeout' => 43200,
|
||||
'cookieSecure' => null,
|
||||
'loginWindowSeconds' => 900,
|
||||
'loginLockoutSeconds' => 900,
|
||||
'loginMaxAttempts' => 5,
|
||||
'loginMaxAddressAttempts' => 20,
|
||||
'maxContentBytes' => 5 * 1024 * 1024,
|
||||
'maxRequestBytes' => 6 * 1024 * 1024,
|
||||
'maxManagedPages' => 5000,
|
||||
'maxScannedEntries' => 20000,
|
||||
'maxDraftBytes' => 250 * 1024 * 1024,
|
||||
'maxSchedules' => 100,
|
||||
'maxScheduledBytes' => 250 * 1024 * 1024,
|
||||
'maxRevisionsPerPage' => 50,
|
||||
'maxRevisionsTotal' => 2000,
|
||||
'maxRevisionBytes' => 500 * 1024 * 1024,
|
||||
'auditMaxFileBytes' => 10 * 1024 * 1024,
|
||||
'auditRetentionDays' => 90,
|
||||
],
|
||||
// Upload constraints limit both individual image complexity and total disk consumption.
|
||||
'uploads' => [
|
||||
'maxFileBytes' => 10 * 1024 * 1024,
|
||||
'maxWidth' => 8192,
|
||||
'maxHeight' => 8192,
|
||||
'maxPixels' => 24 * 1024 * 1024,
|
||||
'maxFiles' => 2000,
|
||||
'maxTotalBytes' => 500 * 1024 * 1024,
|
||||
],
|
||||
// Record authentication and content-management activity in daily files under cms/logs/.
|
||||
'audit' => true,
|
||||
// Set to true if you do not want to see the welcome page and want to go straight to your site
|
||||
// Skip the built-in welcome page and open the public site immediately after login.
|
||||
'skipWelcomePage' => false,
|
||||
// Show full URL of the page being edited on the control bar
|
||||
'showFullUrl' => true
|
||||
// Display the path of the page being edited in the administration toolbar.
|
||||
'showFullUrl' => true,
|
||||
// CSS class used to mark page regions as editable. Use a single class name without a leading dot.
|
||||
'editableClass' => 'editable',
|
||||
];
|
||||
|
||||
// Merge deployment credentials and overrides from the untracked local configuration, when present.
|
||||
$localConfigPath = __DIR__ . '/config.local.php';
|
||||
if (is_file($localConfigPath)) {
|
||||
$localConfig = require $localConfigPath;
|
||||
if (!is_array($localConfig)) {
|
||||
throw new \RuntimeException('cms/config.local.php must return an array');
|
||||
}
|
||||
$config = array_replace_recursive($config, $localConfig);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<?php
|
||||
require_once "../config.php";
|
||||
/**
|
||||
* JSON API entry point for authenticated CMS operations.
|
||||
*
|
||||
* The controller performs authentication, authorisation, CSRF validation, and dispatch. Keeping
|
||||
* this file thin makes it harder for an endpoint to wander off and invent its own security rules.
|
||||
*/
|
||||
|
||||
spl_autoload_register(function ($class) {
|
||||
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
|
||||
require_once "../src/{$classPath}.php";
|
||||
});
|
||||
// Load the site-specific credentials, roles, and content settings.
|
||||
require_once __DIR__ . '/../bootstrap.php';
|
||||
|
||||
use NeoCMS\CMSController;
|
||||
|
||||
// Instantiate the controller with necessary dependencies
|
||||
// Dispatch the current request and allow the controller to emit its JSON response.
|
||||
$controller = new CMSController($config ?? []);
|
||||
$controller->handleRequest();
|
||||
+432
-45
@@ -1,54 +1,97 @@
|
||||
/* Full-height application shell: the toolbar takes what it needs and the preview takes the rest. */
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.pageContainer {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Dark, wrapping administration toolbar shared by wide and narrow screens. */
|
||||
.pageContainer .controls {
|
||||
flex: 0 1 auto;
|
||||
height: 60px;
|
||||
flex: 0 0 auto;
|
||||
min-height: 60px;
|
||||
position: relative;
|
||||
background-color: #1a1d20;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
-webkit-box-shadow: 0 10px 13px -7px #000000, -1px -1px 15px 5px rgba(0, 0, 0, 0);
|
||||
box-shadow: 0 10px 13px -7px #000000, -1px -1px 15px 5px rgba(0, 0, 0, 0);
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
grid-template-areas: "logo url user" "logo url buttons";
|
||||
align-items: center;
|
||||
gap: 6px 16px;
|
||||
padding: 10px 16px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Same-origin preview iframe; JavaScript narrows its width for responsive previews. */
|
||||
.pageContainer .frame {
|
||||
flex: 1 1 auto;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Product mark remains a fixed size while neighbouring controls wrap around it. */
|
||||
.controls .logo {
|
||||
background-image: url("/cms/img/logo.png");
|
||||
background-image: url("../img/logo.png");
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: block;
|
||||
position: absolute;
|
||||
grid-area: logo;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* Top right: user and Log out; the menu buttons sit directly beneath them. */
|
||||
.controls .buttonContainer {
|
||||
width: 400px;
|
||||
margin: 12px 0;
|
||||
position: absolute;
|
||||
right: 110px;
|
||||
grid-area: buttons;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 7px 12px;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
/* CSS */
|
||||
/* Shared toolbar button treatment, including keyboard focus and destructive emphasis. */
|
||||
.headerButton {
|
||||
/*display: flex;*/
|
||||
flex-direction: column;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
padding: 8px 14px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Roboto', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
background: #6E6D70;
|
||||
box-shadow: 0px 0.5px 1px rgba(0, 0, 0, 0.1), inset 0px 0.5px 0.5px rgba(255, 255, 255, 0.5), 0px 0px 0px 0.5px rgba(0, 0, 0, 0.12);
|
||||
@@ -63,44 +106,71 @@ body {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.headerButton.dangerButton {
|
||||
background-color: #6E4D50;
|
||||
.headerButton:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
/* Current page path and unpublished-state label. */
|
||||
.controls #urlbox {
|
||||
margin: 15px 70px;
|
||||
text-align: center;
|
||||
font-size: 1.5em;
|
||||
position: absolute;
|
||||
grid-area: url;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.3;
|
||||
color: #dee2e6;
|
||||
}
|
||||
|
||||
.controls #urlbox div {
|
||||
float: right;
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Compact account, role, logout, and operational-warning area. */
|
||||
.controls .loggedInDetails {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 10px;
|
||||
grid-area: user;
|
||||
justify-self: end;
|
||||
color: #dee2e6;
|
||||
text-align: right;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Name and Log out share one line. */
|
||||
.controls .loggedInDetails br:first-of-type { display: none; }
|
||||
.controls .loggedInDetails .link-button { margin-left: 10px; }
|
||||
|
||||
.link-button {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: #9ecbff;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Legacy list support retained for installations with locally customised toolbar markup. */
|
||||
.controls .buttonContainer ul {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.controls .buttonContainer li {
|
||||
display: inline;
|
||||
margin-right: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
New Page Modal
|
||||
*/
|
||||
/* New-page dialogue: template selection followed by the destination filename. */
|
||||
|
||||
#newPageDialog {
|
||||
display: none;
|
||||
@@ -132,38 +202,355 @@ New Page Modal
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
File List Modal
|
||||
*/
|
||||
/* Page-picker dialogue and searchable action rows. */
|
||||
|
||||
#fileListDialog {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filelist-content {
|
||||
padding: 20px;
|
||||
background-color: #fefefe;
|
||||
border: 1px solid #888;
|
||||
width: 400px;
|
||||
margin: auto;
|
||||
#pageSearch {
|
||||
width: 100%;
|
||||
padding: 9px;
|
||||
margin-bottom: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#fileList {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
.cms-dialog label {
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#fileList li {
|
||||
padding: 10px;
|
||||
/* Consistent form controls across SEO, scheduling, shared-content, and menu dialogues. */
|
||||
.cms-dialog input[type="text"],
|
||||
.cms-dialog input[type="email"],
|
||||
.cms-dialog input[type="password"],
|
||||
.cms-dialog select,
|
||||
.cms-dialog input[type="url"],
|
||||
.cms-dialog input[type="datetime-local"],
|
||||
.cms-dialog textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Content-editor actions sit beneath TinyMCE now that no Bootstrap modal is required. */
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Light, regular-weight buttons; Publish Now is the only filled one, in green. */
|
||||
#editModal .editor-actions button {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid #c9cfd8;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
background-color: #f0f0f0;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
#fileList li:hover {
|
||||
background-color: #e0e0e0;
|
||||
#editModal .editor-actions button:hover {
|
||||
background: #f3f5f8;
|
||||
}
|
||||
|
||||
#editModal .editor-actions #saveBtn {
|
||||
border-color: #9db8d8;
|
||||
color: #2f5a8a;
|
||||
}
|
||||
|
||||
#editModal .editor-actions #saveBtn:hover {
|
||||
background: #eaf1fb;
|
||||
}
|
||||
|
||||
#editModal .editor-actions .publish-now {
|
||||
background: #2e7d4f;
|
||||
border-color: #2e7d4f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#editModal .editor-actions .publish-now:hover {
|
||||
background: #256a42;
|
||||
}
|
||||
|
||||
/* Tool launcher expands to available width without requiring a row of tiny buttons. */
|
||||
.tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tool-grid button,
|
||||
.cms-dialog button,
|
||||
#fileList button {
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Media cards keep previews useful while accommodating long generated filenames. */
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
max-height: 65vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.media-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.media-card img {
|
||||
width: 100%;
|
||||
height: 130px;
|
||||
object-fit: contain;
|
||||
background: #eee;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Reusable rows serve revisions, schedules, activity, shared blocks, and menus. */
|
||||
.list-row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
text-align: left;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Findings use a restrained warning treatment; no flashing sirens are required. */
|
||||
.issue {
|
||||
margin: 7px 0;
|
||||
padding: 9px;
|
||||
border-left: 4px solid #b52b3a;
|
||||
background: #fff0f1;
|
||||
}
|
||||
|
||||
/* Semantic status colours shared by checks and destructive actions. */
|
||||
.success-text {
|
||||
color: #18733c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.danger-text {
|
||||
color: #a51d2d;
|
||||
}
|
||||
|
||||
.redText {
|
||||
color: #ff4d4d;
|
||||
}
|
||||
|
||||
/* At medium widths the buttons take their own row under the user block; the path stays beside the logo. */
|
||||
@media (max-width: 1100px) {
|
||||
.pageContainer .controls {
|
||||
grid-template-areas: "logo url user" "buttons buttons buttons";
|
||||
}
|
||||
}
|
||||
|
||||
/* On small screens controls stack into touch-friendly full-width rows; the user block stays top right. */
|
||||
@media (max-width: 760px) {
|
||||
.pageContainer .controls {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-areas: "logo user" "url url" "buttons buttons";
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.controls .logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
flex: 1 1 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.controls #urlbox {
|
||||
text-align: left;
|
||||
}
|
||||
.controls .buttonContainer ul {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.controls .buttonContainer li,
|
||||
.controls .buttonContainer .headerButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.controls #urlbox div {
|
||||
display: block;
|
||||
margin-left: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Site scan dialogue: selectable page rows and the option group. */
|
||||
#siteScanOptions { border: 1px solid #ccc; margin: 12px 0; padding: 8px 12px; }
|
||||
#siteScanOptions label { display: block; margin: 4px 0; }
|
||||
#siteScanPages { max-height: 40vh; overflow: auto; }
|
||||
.scan-row { display: block; cursor: pointer; }
|
||||
.scan-note { color: #555; font-size: 0.9em; }
|
||||
|
||||
/* Image dialogue: thumbnail picker and address fields. */
|
||||
#imagePicker img { cursor: pointer; }
|
||||
#imageForm label { display: block; margin: 8px 0; }
|
||||
#imageForm input[type="text"] { display: block; width: 100%; box-sizing: border-box; }
|
||||
/* Page picker: full-width table with a document icon, draft badge, and per-row actions. */
|
||||
.filelist-content { padding: 4px 0 0; }
|
||||
.table-wrap { max-height: 60vh; overflow: auto; border: 1px solid #dfe3ea; border-radius: 8px; }
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.data-table th {
|
||||
position: sticky; top: 0; z-index: 1; text-align: left; padding: 10px 14px;
|
||||
background: #f3f5f8; color: #4b5563; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid #dfe3ea;
|
||||
}
|
||||
.data-table td { padding: 10px 14px; border-bottom: 1px solid #edf0f4; vertical-align: middle; }
|
||||
.data-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.data-table tbody tr:nth-child(even) { background: #fafbfc; }
|
||||
.page-row { cursor: pointer; }
|
||||
.page-row:hover { background: #eaf1fb !important; }
|
||||
.page-cell { white-space: nowrap; }
|
||||
.doc-icon { vertical-align: middle; margin-right: 8px; color: #4a7bb7; flex: none; }
|
||||
.data-table .page-open { border: 0; background: none; padding: 0; font: inherit; font-weight: 600; color: #1f2937; cursor: pointer; text-align: left; }
|
||||
.data-table .page-open:hover, .data-table .page-open:focus { color: #2563a8; text-decoration: underline; }
|
||||
.page-title { color: #4b5563; }
|
||||
.page-date { color: #6b7280; white-space: nowrap; }
|
||||
.row-actions { white-space: nowrap; text-align: right; }
|
||||
.row-actions button, .row-button { margin-left: 4px; padding: 4px 9px; font-size: 12px; cursor: pointer; }
|
||||
.badge-draft {
|
||||
margin-left: 10px; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600;
|
||||
background: #fff3d6; color: #8a5a00; border: 1px solid #f0d58a;
|
||||
}
|
||||
.badge-locked { background: #fde2e1; color: #8a1f1f; border-color: #f3b4b2; }
|
||||
@media (max-width: 700px) { #fileList .page-title, #fileList th:nth-child(2), #fileList .page-date, #fileList th:nth-child(3) { display: none; } }
|
||||
|
||||
/* Site scan progress bar and file count. */
|
||||
#siteScanProgress { margin: 0 0 12px; }
|
||||
#scanProgressBar { width: 100%; height: 16px; accent-color: #4a7bb7; }
|
||||
#scanProgressText { margin-top: 4px; font-size: 13px; color: #4b5563; }
|
||||
|
||||
|
||||
/* Shared polish for dialogue content: stat tiles, section headings, empty states, badges. */
|
||||
.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 14px; }
|
||||
.stat { padding: 10px 14px; background: #f3f5f8; border: 1px solid #dfe3ea; border-radius: 8px; font-size: 13px; color: #4b5563; }
|
||||
.stat strong { font-size: 18px; color: #1f2937; margin-right: 2px; }
|
||||
.dialog-section { margin: 18px 0 8px; font-size: 14px; color: #374151; }
|
||||
.empty-state { padding: 18px; text-align: center; color: #6b7280; background: #fafbfc; border: 1px dashed #dfe3ea; border-radius: 8px; }
|
||||
.page-sub { display: block; margin: 2px 0 0 26px; font-size: 12px; font-weight: 400; color: #6b7280; white-space: normal; }
|
||||
.check-cell { width: 34px; text-align: center; }
|
||||
.data-table .page-cell strong { font-weight: 600; color: #1f2937; }
|
||||
.badge-pending, .badge-failed { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; }
|
||||
.badge-pending { background: #e7f0fb; color: #1e5a99; border: 1px solid #bcd3ee; }
|
||||
.badge-failed { background: #fdeaec; color: #a51d2d; border: 1px solid #f0b8bf; }
|
||||
.issue { border-radius: 6px; }
|
||||
|
||||
/* New-page templates as selectable rows. */
|
||||
#radioList { display: grid; gap: 8px; margin-bottom: 14px; }
|
||||
.template-option { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid #dfe3ea; border-radius: 8px; cursor: pointer; }
|
||||
.template-option:hover { background: #f3f7fc; }
|
||||
.template-option:has(input:checked) { border-color: #4a7bb7; background: #eaf1fb; }
|
||||
.template-option .doc-icon { margin: 0; }
|
||||
|
||||
/* Media library cards. */
|
||||
.media-card { border-radius: 10px; border-color: #dfe3ea; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); transition: box-shadow 0.15s; }
|
||||
.media-card:hover { box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); }
|
||||
.media-card img { border-radius: 6px; }
|
||||
.media-card strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
|
||||
.media-card small { color: #6b7280; }
|
||||
|
||||
/* Snackbar: bottom-centre toast for success and error messages. */
|
||||
#message-bar {
|
||||
position: fixed; left: 50%; bottom: 28px; z-index: 100000;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
min-width: 280px; max-width: min(560px, calc(100vw - 32px)); box-sizing: border-box;
|
||||
padding: 12px 10px 12px 14px; background: #fff; color: #1f2937;
|
||||
border-left: 5px solid #18733c; border-radius: 10px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
|
||||
font: 14px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
opacity: 0; transform: translate(-50%, 24px); pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
#message-bar[hidden] { display: none; }
|
||||
#message-bar.show { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; }
|
||||
#message-bar.is-error { border-left-color: #a51d2d; }
|
||||
.snack-icon { flex: none; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: #18733c; color: #fff; font-size: 14px; font-weight: 700; }
|
||||
#message-bar.is-error .snack-icon { background: #a51d2d; }
|
||||
#message-bar.is-success .snack-icon::before { content: "\2713"; }
|
||||
#message-bar.is-error .snack-icon::before { content: "!"; }
|
||||
.snack-text { flex: 1; word-break: break-word; }
|
||||
.snack-close { flex: none; border: 0; background: none; padding: 0 6px; font-size: 22px; line-height: 1; color: #6b7280; cursor: pointer; }
|
||||
.snack-close:hover { color: #111827; }
|
||||
@media (prefers-reduced-motion: reduce) { #message-bar { transition: none; } }
|
||||
|
||||
/* New-page dialogue: fill the dialogue instead of sitting in a bordered box inside it. */
|
||||
.newpage-content { padding: 0; background: none; border: 0; width: auto; }
|
||||
#newPageForm > label[for="filename"] { margin: 4px 0 6px; font-weight: 600; }
|
||||
#newPageForm input[type="text"] { width: 100%; box-sizing: border-box; padding: 9px; margin-bottom: 16px; border: 1px solid #c9cfd8; border-radius: 6px; }
|
||||
/* Dialogue buttons use the small row-button metrics; primary actions are filled blue. */
|
||||
.ui-dialog .ui-dialog-content :where(button:not([class*="tox"]):not([class*="ui-"]):not(.page-open)) {
|
||||
padding: 4px 9px; font-family: inherit; font-size: 12px; font-weight: 400; color: #374151;
|
||||
background: #fff; border: 1px solid #c9cfd8; border-radius: 4px; cursor: pointer;
|
||||
}
|
||||
.ui-dialog .ui-dialog-content :where(button:not([class*="tox"]):not([class*="ui-"]):not(.page-open)):hover { background: #f3f5f8; }
|
||||
.ui-dialog .ui-dialog-content button.danger-text { color: #a51d2d; }
|
||||
.ui-dialog .ui-dialog-content button[type="submit"], .ui-dialog .ui-dialog-content .step-next, .ui-dialog .ui-dialog-content #siteScanApply {
|
||||
border-color: #3b6ea5; background: #3b6ea5; color: #fff; font-weight: 600;
|
||||
}
|
||||
.ui-dialog .ui-dialog-content button[type="submit"]:hover, .ui-dialog .ui-dialog-content .step-next:hover, .ui-dialog .ui-dialog-content #siteScanApply:hover { background: #2f5a8a; }
|
||||
#fileList .row-actions button { padding: 4px 9px; font-size: 12px; }
|
||||
/* Action buttons (editor Save/Schedule/Publish, dialogue Save/Next/Apply) are a step larger than row buttons. */
|
||||
#editModal .editor-actions button, .ui-dialog .ui-dialog-content button[type="submit"], .ui-dialog .ui-dialog-content .step-next, .ui-dialog .ui-dialog-content .step-back, .ui-dialog .ui-dialog-content #siteScanApply { padding: 6px 14px; font-size: 13px; }
|
||||
#sharedList, #menuList { margin-bottom: 16px; }
|
||||
|
||||
/* Device-preview buttons are icon-only; the active one is highlighted. */
|
||||
.viewport-button {
|
||||
min-width: 42px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.viewport-button.active {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.85);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
/* New-page wizard. */
|
||||
.step-indicator { margin: 0 0 4px; font-size: 12px; color: #6b7280; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.step-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
#menuChoice { display: grid; gap: 8px; }
|
||||
#newPageForm section[hidden] { display: none; }
|
||||
|
||||
/* Wizard lists reuse the page-table look. */
|
||||
#radioList, #menuChoice { display: block; margin-bottom: 14px; }
|
||||
#radioList .table-wrap, #menuChoice .table-wrap { max-height: 46vh; overflow: auto; }
|
||||
|
||||
/* Users dialogue. */
|
||||
#usersDialog form { margin-bottom: 8px; }
|
||||
#usersDialog #inviteLink { width: calc(100% - 100px); box-sizing: border-box; padding: 6px; }
|
||||
|
||||
/* Important administrator recommendations (for example the data directory notice). */
|
||||
.important-note, .cms-dialog .scan-note.important-note { color: #b3141f; font-weight: 600; }
|
||||
|
||||
/* Media library: upload, category tabs, and file cards. */
|
||||
.media-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 10px; }
|
||||
.media-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.media-tab[aria-selected="true"] { background: #3b6ea5 !important; border-color: #3b6ea5 !important; color: #fff !important; }
|
||||
.media-thumb { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; height: 130px; background: #eef2f5; color: #4a7bb7; font-weight: 600; font-size: 12px; }
|
||||
.media-thumb svg { width: 40px; height: 40px; }
|
||||
.media-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
#mediaUploadReport:empty { display: none; }
|
||||
|
||||
/* New page wizard sits under the page list in the Pages dialogue. */
|
||||
#newPageSection { margin-top: 22px; padding-top: 6px; border-top: 1px solid #d4dde4; }
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/* Standalone login palette kept deliberately separate from the administration application. */
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #111519;
|
||||
}
|
||||
|
||||
/* Centred credential card with enough contrast to survive a late-night publishing session. */
|
||||
.login-container {
|
||||
width: 350px;
|
||||
margin: 100px auto;
|
||||
@@ -12,6 +14,7 @@ body {
|
||||
box-shadow: 0 4px 10px rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
/* Form typography and labels follow the same light-on-dark visual hierarchy. */
|
||||
.login-container h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
@@ -24,6 +27,7 @@ body {
|
||||
color: #dee2e6;
|
||||
}
|
||||
|
||||
/* Inputs use border-box so their declared width includes padding and borders. */
|
||||
.login-container input[type="text"],
|
||||
.login-container input[type="password"] {
|
||||
width: 100%;
|
||||
@@ -36,6 +40,7 @@ body {
|
||||
color: #dee2e6;
|
||||
}
|
||||
|
||||
/* The single primary action spans the card and receives a clear hover state. */
|
||||
.login-container button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
@@ -51,17 +56,20 @@ body {
|
||||
background-color: #005bb5;
|
||||
}
|
||||
|
||||
/* Authentication failures remain visible without exposing sensitive detail. */
|
||||
.error-message {
|
||||
color: #ff4d4d;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Brand artwork centres independently of its source image dimensions. */
|
||||
.logo {
|
||||
display: block;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
/* Low-contrast project credit: present, but not competing with the login form. */
|
||||
.shamelessPlug {
|
||||
margin: 12px 0 -30px 0;
|
||||
padding: 0;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# NeoCMS metadata may contain drafts and revision history; never serve it directly.
|
||||
Require all denied
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
// Metadata is private even when a web server ignores the accompanying .htaccess file.
|
||||
http_response_code(404);
|
||||
+138
-55
@@ -1,103 +1,186 @@
|
||||
<?php
|
||||
// upload.php
|
||||
/**
|
||||
* Authenticated media-upload endpoint (images, documents, video, audio), also used by TinyMCE for pasted images.
|
||||
*
|
||||
* Every file is checked by MediaTypes::inspect (extension allow-list, content sniffing, code scanning) before it receives a
|
||||
* server-generated filename. Trusting only the browser-supplied extension would be admirably optimistic.
|
||||
*/
|
||||
|
||||
require_once "config.php";
|
||||
|
||||
// Autoload classes (if not using Composer)
|
||||
spl_autoload_register(function ($class) {
|
||||
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
|
||||
require_once "./src/{$classPath}.php";
|
||||
});
|
||||
// Load credentials, role assignments, and shared application settings.
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
use NeoCMS\Activity;
|
||||
use NeoCMS\Authentication;
|
||||
use NeoCMS\FileStore;
|
||||
use NeoCMS\Logger;
|
||||
use NeoCMS\MediaTypes;
|
||||
use NeoCMS\SecurityHeaders;
|
||||
|
||||
// Ensure the user is authenticated
|
||||
$authentication = new Authentication($config['authentication']??[]);
|
||||
SecurityHeaders::json(isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null);
|
||||
|
||||
// Authentication is required before any upload details are processed.
|
||||
$authentication = new Authentication(...\NeoCMS\UserStore::authArgs($config));
|
||||
if (!$authentication->isLoggedIn()) {
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'Unauthorised']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Set appropriate headers
|
||||
header('Content-Type: application/json');
|
||||
// Editors may upload; unauthorised roles receive a clear forbidden response.
|
||||
if (!$authentication->can('upload')) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Your role cannot upload files']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Allowed MIME types and extensions
|
||||
$allowed_types = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'text/pdf' => 'pdf',
|
||||
'text/docx' => 'docx',
|
||||
'text/xlsx' => 'xlsx',
|
||||
'text/doc' => 'doc',
|
||||
];
|
||||
// Uploads alter server state and therefore accept POST only.
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
header('Allow: POST');
|
||||
echo json_encode(['error' => 'Invalid request method']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Maximum file size in bytes (50MB)
|
||||
$max_file_size = 50 * 1024 * 1024;
|
||||
// Accept the token as form data or a conventional request header.
|
||||
$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
|
||||
if (!$authentication->isValidCsrfToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check if a file has been uploaded
|
||||
// Apply configured byte, dimension, pixel-count, file-count, and total-storage limits.
|
||||
$uploadConfig = is_array($config['uploads'] ?? null) ? $config['uploads'] : [];
|
||||
$max_file_size = max(1024, (int) ($uploadConfig['maxFileBytes'] ?? 10 * 1024 * 1024));
|
||||
$maxWidth = max(1, (int) ($uploadConfig['maxWidth'] ?? 8192));
|
||||
$maxHeight = max(1, (int) ($uploadConfig['maxHeight'] ?? 8192));
|
||||
$maxPixels = max(1, (int) ($uploadConfig['maxPixels'] ?? 24 * 1024 * 1024));
|
||||
$maxFiles = max(1, (int) ($uploadConfig['maxFiles'] ?? 2000));
|
||||
$maxTotalBytes = max($max_file_size, (int) ($uploadConfig['maxTotalBytes'] ?? 500 * 1024 * 1024));
|
||||
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > $max_file_size + 1024 * 1024) {
|
||||
http_response_code(413);
|
||||
echo json_encode(['error' => 'This file is larger than the ' . round($max_file_size / 1048576, 1) . ' MB limit.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Continue only when PHP reports a complete upload.
|
||||
if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
|
||||
$file = $_FILES['file'];
|
||||
|
||||
// Check file size
|
||||
// Enforce the application limit even if php.ini permits larger files.
|
||||
if ($file['size'] > $max_file_size) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'File size exceeds the maximum limit.']);
|
||||
echo json_encode(['error' => 'This file is larger than the ' . round($max_file_size / 1048576, 1) . ' MB limit.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate the file type
|
||||
// A pasted image may arrive without an extension; name it from its detected type so it can be checked like any other file.
|
||||
$originalName = (string) $file['name'];
|
||||
if (pathinfo($originalName, PATHINFO_EXTENSION) === '') {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime_type = finfo_file($finfo, $file['tmp_name']);
|
||||
$detected = (string) finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
$originalName .= '.' . (['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'][$detected] ?? '');
|
||||
}
|
||||
|
||||
if (!array_key_exists($mime_type, $allowed_types)) {
|
||||
// Extension allow-list, content sniffing, code scanning and per-format checks (see MediaTypes::inspect).
|
||||
try {
|
||||
$inspection = MediaTypes::inspect($file['tmp_name'], $originalName, [
|
||||
'maxBytes' => $max_file_size, 'maxWidth' => $maxWidth, 'maxHeight' => $maxHeight, 'maxPixels' => $maxPixels,
|
||||
]);
|
||||
} catch (\RuntimeException $exception) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid file type.']);
|
||||
echo json_encode(['error' => $exception->getMessage()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate that the file is a real image
|
||||
if (getimagesize($file['tmp_name']) === false) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Uploaded file is not a valid image.']);
|
||||
exit;
|
||||
}
|
||||
// Generated names keep the original filename out of the URL namespace and allow only one extension.
|
||||
$filename = MediaTypes::storedName($originalName, $inspection['ext']);
|
||||
|
||||
// Generate a unique name for the image
|
||||
$extension = $allowed_types[$mime_type];
|
||||
$filename = uniqid('', true) . '.' . $extension;
|
||||
|
||||
// Set the upload directory
|
||||
$upload_dir = realpath(__DIR__ . '/../uploads') . DIRECTORY_SEPARATOR;
|
||||
|
||||
// Ensure the upload directory exists
|
||||
if (!is_dir($upload_dir) && !mkdir($upload_dir, 0755, true)) {
|
||||
// Create the public upload directory on a fresh installation.
|
||||
$uploadRoot = __DIR__ . '/../uploads';
|
||||
if (!is_dir($uploadRoot) && !mkdir($uploadRoot, 0755, true)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Failed to create upload directory.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Set the target file path
|
||||
$upload_dir = realpath($uploadRoot);
|
||||
if ($upload_dir === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Upload directory is invalid.']);
|
||||
exit;
|
||||
}
|
||||
$upload_dir .= DIRECTORY_SEPARATOR;
|
||||
@chmod($upload_dir, 0755);
|
||||
|
||||
// Build the final path from the canonical upload directory and generated safe filename.
|
||||
$target_file = $upload_dir . $filename;
|
||||
|
||||
// Move the uploaded file to the target directory
|
||||
// Serialise quota checks and moves so concurrent uploads cannot race past the storage ceiling.
|
||||
$store = new FileStore((string) ($config['dataDirectory'] ?? (__DIR__ . '/data')));
|
||||
$lockPath = $store->directory('locks') . 'uploads.lock';
|
||||
$uploadLock = fopen($lockPath, 'c+');
|
||||
if ($uploadLock === false || !flock($uploadLock, LOCK_EX)) {
|
||||
if (is_resource($uploadLock)) {
|
||||
fclose($uploadLock);
|
||||
}
|
||||
http_response_code(503);
|
||||
echo json_encode(['error' => 'Upload service is temporarily unavailable.']);
|
||||
exit;
|
||||
}
|
||||
@chmod($lockPath, 0600);
|
||||
|
||||
$existingFiles = array_values(array_filter(glob($upload_dir . '*') ?: [], 'is_file'));
|
||||
$existingBytes = array_sum(array_map(static fn(string $path): int => (int) filesize($path), $existingFiles));
|
||||
if (count($existingFiles) >= $maxFiles || $existingBytes + (int) $file['size'] > $maxTotalBytes) {
|
||||
flock($uploadLock, LOCK_UN);
|
||||
fclose($uploadLock);
|
||||
http_response_code(507);
|
||||
echo json_encode(['error' => 'Upload storage quota has been reached.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// move_uploaded_file() additionally confirms that PHP received the source as an upload.
|
||||
if (move_uploaded_file($file['tmp_name'], $target_file)) {
|
||||
// Set file permissions
|
||||
// Public images need read access but no executable or world-writable permissions.
|
||||
chmod($target_file, 0644);
|
||||
|
||||
// Return a JSON response with the image location
|
||||
$location = '/uploads/' . $filename;
|
||||
echo json_encode(['location' => $location]);
|
||||
// Seed media metadata from the original basename; editors can improve the alt text later.
|
||||
$location = $config['basePath'] . '/uploads/' . $filename;
|
||||
$metadata = $store->read('media');
|
||||
$originalBase = pathinfo((string) $file['name'], PATHINFO_FILENAME);
|
||||
$altText = substr(preg_replace('/[\x00-\x1F\x7F]/u', '', $originalBase) ?? '', 0, 200);
|
||||
$metadata[$filename] = ['alt' => $altText, 'original' => substr(preg_replace('/[\x00-\x1F\x7F]/u', '', basename($originalName)) ?? '', 0, 200), 'by' => $authentication->getLoggedInUser()];
|
||||
try {
|
||||
$store->write('media', $metadata);
|
||||
} catch (\Throwable $exception) {
|
||||
@unlink($target_file);
|
||||
flock($uploadLock, LOCK_UN);
|
||||
fclose($uploadLock);
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Unable to record uploaded media.']);
|
||||
exit;
|
||||
}
|
||||
flock($uploadLock, LOCK_UN);
|
||||
fclose($uploadLock);
|
||||
// The file is saved by now, so a failure to write the audit entry must not fail the upload.
|
||||
try {
|
||||
(new Activity($store, new Logger($config['audit'] ?? true, $config['security'] ?? [])))
|
||||
->record($authentication->getLoggedInUser(), 'Uploaded ' . (['imagery' => 'image', 'documents' => 'document', 'video' => 'video', 'audio' => 'audio'][$inspection['category']]), $filename . ' (' . max(1, (int) round(((int) $file['size']) / 1024)) . ' KB)');
|
||||
} catch (\Throwable $exception) {
|
||||
// Deliberately ignored.
|
||||
}
|
||||
echo json_encode(['location' => $location, 'name' => $filename, 'category' => $inspection['category']]);
|
||||
} else {
|
||||
// Return an error response
|
||||
flock($uploadLock, LOCK_UN);
|
||||
fclose($uploadLock);
|
||||
// A failed move is a server error because validation has already succeeded.
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Failed to move uploaded file.']);
|
||||
}
|
||||
} else {
|
||||
// Handle upload errors
|
||||
// Translate PHP's numeric upload failures into messages an editor can act upon.
|
||||
$error_message = 'No file uploaded.';
|
||||
if (isset($_FILES['file']['error'])) {
|
||||
switch ($_FILES['file']['error']) {
|
||||
|
||||
+248
-64
@@ -1,115 +1,299 @@
|
||||
<?php
|
||||
require_once "config.php";
|
||||
// Autoload classes (if not using Composer)
|
||||
spl_autoload_register(function ($class) {
|
||||
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
|
||||
require_once "./src/{$classPath}.php";
|
||||
});
|
||||
/** Server-rendered shell for the authenticated NeoCMS administration application. */
|
||||
|
||||
// Load configuration before initialising sessions and role-aware controls.
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
use NeoCMS\Authentication;
|
||||
use NeoCMS\SecurityHeaders;
|
||||
|
||||
// Instantiate the Authentication class
|
||||
$authentication = new Authentication($config['authentication'] ?? []);
|
||||
// Prepare the session values required by both the rendered shell and JavaScript client.
|
||||
$authentication = new Authentication(...\NeoCMS\UserStore::authArgs($config));
|
||||
$csrfToken = $authentication->getCsrfToken();
|
||||
// Only a single valid CSS class is allowed; malformed values fall back safely.
|
||||
$editableClass = $config['editableClass'] ?? 'editable';
|
||||
if (!is_string($editableClass) || !preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $editableClass)) {
|
||||
$editableClass = 'editable';
|
||||
}
|
||||
|
||||
// Check if the user is logged in
|
||||
// Keep the administration shell private by redirecting anonymous requests to login.
|
||||
if (!$authentication->isLoggedIn()) {
|
||||
// Redirect to the login page
|
||||
header("Location: /cms/login/");
|
||||
header("Location: " . $config['basePath'] . "/cms/login/");
|
||||
exit;
|
||||
}
|
||||
SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null);
|
||||
?>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en-gb">
|
||||
<head>
|
||||
<title>NeoCMS</title>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://code.jquery.com/ui/1.14.0/jquery-ui.min.js" crossorigin="anonymous"></script>
|
||||
<script src="/cms/tinymce/tinymce.min.js"></script>
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://code.jquery.com/ui/1.14.0/themes/base/jquery-ui.css">
|
||||
<link rel="stylesheet" href="/cms/css/editor.css">
|
||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js"></script>
|
||||
<meta name="csrf-token" content="<?php echo htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<meta name="neo-base-path" content="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<meta name="neo-editable-class" content="<?php echo htmlspecialchars($editableClass, ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha384-fgGyf7Mo7DURSOMnOy7ed+dkq5Job205Gnzu6QIg0BOHKaqt4D76Dt8VlDCzcMHV" crossorigin="anonymous"></script>
|
||||
<script src="https://code.jquery.com/ui/1.14.2/jquery-ui.min.js" integrity="sha384-tBcEcHGtNy7/Mx08+YxuvQ6v6s0N2jgehtFiT+bLtGwTj/txXtB/L5GqXfggm5sS" crossorigin="anonymous"></script>
|
||||
<script src="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/tinymce/tinymce.min.js"></script>
|
||||
<link rel="stylesheet" href="https://code.jquery.com/ui/1.14.2/themes/base/jquery-ui.css" integrity="sha384-pUvA/6DQjteMxpaV6uGxZ1QuYrFLJgrLMvBWf06VcJIg6ky/Y5m3UZJlrv11V1I+" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/css/editor.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="message-bar" style="display:none;"></div>
|
||||
<body data-role="<?php echo htmlspecialchars($authentication->getRole(), ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<!-- Status messages sit outside the flex layout so they may overlay the full viewport. -->
|
||||
<div id="message-bar" role="status" aria-live="polite" hidden>
|
||||
<span class="snack-icon" aria-hidden="true"></span>
|
||||
<span class="snack-text"></span>
|
||||
<button type="button" class="snack-close" aria-label="Dismiss message">×</button>
|
||||
</div>
|
||||
<div class="pageContainer">
|
||||
<div class="controls">
|
||||
<div class="logo"></div>
|
||||
<?php if ($config['showFullUrl']) {?>
|
||||
<div id="urlbox"></div>
|
||||
<?php } ?>
|
||||
<!-- Primary content tools and publication controls are grouped into two wrapping rows. -->
|
||||
<div class="buttonContainer">
|
||||
<ul>
|
||||
<li>
|
||||
<button class="headerButton" id="newPage" role="button">New Page</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="headerButton" id="selectPage" role="button">Select Page</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="headerButton dangerButton" id="savePage" role="button">Publish Changes</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="toolbar-row">
|
||||
<button class="headerButton" id="dashboardButton">Dashboard</button>
|
||||
<button class="headerButton" id="selectPage">Pages</button>
|
||||
<button class="headerButton" id="mediaButton">Media</button>
|
||||
<button class="headerButton" id="seoButton">SEO</button>
|
||||
<button class="headerButton manage-only" id="menusButton">Navigation Menus</button>
|
||||
<button class="headerButton" id="moreButton">Tools</button>
|
||||
</div>
|
||||
<div class="toolbar-row publish-controls">
|
||||
<button class="headerButton viewport-button active" data-width="100%" title="Desktop view" aria-label="Desktop view" aria-pressed="true"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="4" width="19" height="12.5" rx="1.5"/><path d="M8 20.5h8M12 16.5v4"/></svg></button>
|
||||
<button class="headerButton viewport-button" data-width="768px" title="Tablet view" aria-label="Tablet view" aria-pressed="false"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="2.5" width="14" height="19" rx="2"/><path d="M11 18.5h2"/></svg></button>
|
||||
<button class="headerButton viewport-button" data-width="390px" title="Mobile view" aria-label="Mobile view" aria-pressed="false"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="7" y="2.5" width="10" height="19" rx="2"/><path d="M11 18.5h2"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loggedInDetails">
|
||||
Logged in as: <?php echo $authentication->getLoggedinUser(); ?><br/>
|
||||
<span id="whoami"><?php $profile = \NeoCMS\UserStore::fromConfig($config)->profile($authentication->getLoggedInUser()); echo htmlspecialchars($profile['name'] !== '' ? $profile['name'] : $authentication->getLoggedInUser(), ENT_QUOTES, 'UTF-8'); ?></span>
|
||||
(<?php echo htmlspecialchars($authentication->getRole(), ENT_QUOTES, 'UTF-8'); ?>)<br/>
|
||||
<button id="logoutButton" class="link-button">Log out</button><br/>
|
||||
<?php
|
||||
if (!is_writable("logs/")) {
|
||||
echo "<span class='redText'>Make /cms/logs/ writeable!</span>";
|
||||
echo "<span class='redText'>Make /cms/logs/ writable.</span>";
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
<iframe id="frameContainer" src="<?php echo $config['skipWelcomePage'] ? "/" : "welcome.html"; ?>"
|
||||
class="frame"></iframe>
|
||||
<iframe id="frameContainer" src="<?php echo $config['skipWelcomePage'] ? htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8') . "/" : "welcome.html"; ?>"
|
||||
class="frame" sandbox="allow-same-origin"></iframe>
|
||||
</div>
|
||||
<!-- Modal -->
|
||||
<div id="editModal" class="modal fade" role="dialog">
|
||||
<div class="modal-dialog modal-lg">
|
||||
|
||||
<!-- Modal content-->
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">Edit Content</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- TinyMCE content editor. Save Draft keeps changes private; Schedule Publish and Publish Now act on the whole page. -->
|
||||
<div id="editModal" class="cms-dialog" title="Edit Content">
|
||||
<textarea id="editor"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="saveBtn" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
<div class="editor-actions">
|
||||
<button id="saveBtn" type="button" title="Applies your changes and saves them as a private draft">Save Draft</button>
|
||||
<button id="scheduleBtn" type="button" class="publish-only" title="Publishes this page automatically at a time you choose">Schedule Publish</button>
|
||||
<button id="publishBtn" type="button" class="publish-only publish-now" title="Replaces the live page now; a revision of the old page is kept">Publish Now</button>
|
||||
<button id="closeEditorBtn" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Searchable page picker with role-dependent management actions. -->
|
||||
<div id="fileListDialog" title="Pages">
|
||||
<div class="filelist-content">
|
||||
<input id="pageSearch" type="search" placeholder="Search pages" aria-label="Search pages">
|
||||
<div class="table-wrap">
|
||||
<table id="fileList" class="data-table">
|
||||
<thead>
|
||||
<tr><th>Page</th><th>Title</th><th>Last modified</th><th class="manage-col">Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- JavaScript populates discovered editable pages here. -->
|
||||
</tbody>
|
||||
</table>
|
||||
<p id="pageListEmpty" class="scan-note" hidden>No pages match your search.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--New Page modal -->
|
||||
<div id="newPageDialog" title="Create a New Page">
|
||||
<!-- New page wizard: template, name, navigation group. Administrators only. -->
|
||||
<div id="newPageSection" class="manage-only">
|
||||
<h3 class="dialog-section">New page</h3>
|
||||
<div class="newpage-content">
|
||||
<form id="newPageForm">
|
||||
<p id="newPageStep" class="step-indicator"></p>
|
||||
<section data-step="1">
|
||||
<h3 class="dialog-section">Choose a template</h3>
|
||||
<div id="radioList">
|
||||
<!-- Radio buttons will be populated here -->
|
||||
<!-- JavaScript populates available filesystem templates here. -->
|
||||
</div>
|
||||
<label for="filename">New Filename:</label>
|
||||
<input type="text" id="filename" name="filename" placeholder="Enter new filename" required>
|
||||
|
||||
<button type="submit">Submit</button>
|
||||
<div class="step-actions"><button type="button" class="step-next">Next</button></div>
|
||||
</section>
|
||||
<section data-step="2" hidden>
|
||||
<h3 class="dialog-section">Name your page</h3>
|
||||
<label for="pageName">Page name</label>
|
||||
<input type="text" id="pageName" maxlength="80" placeholder="For example: About us" autocomplete="off">
|
||||
<p class="scan-note">Used as the page title and the navigation link text. The filename is created for you.</p>
|
||||
<div class="step-actions"><button type="button" class="step-back">Back</button><button type="button" class="step-next">Next</button></div>
|
||||
</section>
|
||||
<section data-step="3" hidden>
|
||||
<h3 class="dialog-section">Choose a navigation group</h3>
|
||||
<div id="menuChoice">
|
||||
<!-- JavaScript lists the saved navigation menus here. -->
|
||||
</div>
|
||||
<p class="scan-note">The page is saved as a private draft. It appears on the site, and in this menu, only once you publish it.</p>
|
||||
<div class="step-actions"><button type="button" class="step-back">Back</button><button type="submit">Save</button></div>
|
||||
</section>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page list modal -->
|
||||
<div id="fileListDialog" title="Select an Existing Page">
|
||||
<div class="filelist-content">
|
||||
<ul id="fileList">
|
||||
<!-- File names will be populated here -->
|
||||
</ul>
|
||||
<!-- Operational overview: pending work, schedules, activity, and filesystem warnings. -->
|
||||
<div id="dashboardDialog" class="cms-dialog" title="Dashboard"><div id="dashboardContent"></div></div>
|
||||
|
||||
<!-- Secondary authoring and site-wide management tools. -->
|
||||
<div id="toolsDialog" class="cms-dialog" title="Tools">
|
||||
<div class="tool-grid">
|
||||
<button id="revisionsButton">Revision history</button>
|
||||
<button id="accessibilityButton">Accessibility check</button>
|
||||
<button id="sharedButton" class="manage-only">Shared content</button>
|
||||
<button id="siteScanButton" class="manage-only">Site scan</button>
|
||||
<button id="usersButton">Users</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/cms/js/cms.js"></script>
|
||||
<!-- Reusable uploaded-image browser and metadata editor. -->
|
||||
<div id="mediaDialog" class="cms-dialog" title="Media Library">
|
||||
<div class="media-toolbar">
|
||||
<button type="button" id="mediaUploadButton">Upload files</button>
|
||||
<input type="file" id="mediaFile" multiple hidden accept="<?php echo htmlspecialchars('.' . implode(',.', \NeoCMS\MediaTypes::extensions()), ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<div id="mediaTabs" class="media-tabs" role="tablist" aria-label="Media categories"></div>
|
||||
</div>
|
||||
<p id="mediaHint" class="scan-note"></p>
|
||||
<div id="mediaUploadReport" class="scan-note" role="status" aria-live="polite"></div>
|
||||
<div id="mediaList" class="media-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Page history and publisher-only restoration controls. -->
|
||||
<div id="revisionsDialog" class="cms-dialog" title="Revision History"><div id="revisionsList"></div></div>
|
||||
|
||||
<!-- In-document title, search metadata, canonical URL, and robots settings. -->
|
||||
<div id="seoDialog" class="cms-dialog" title="SEO and Page Settings">
|
||||
<form id="seoForm">
|
||||
<label>Page title<input id="seoTitle" type="text" required></label>
|
||||
<label>Meta description<textarea id="seoDescription"></textarea></label>
|
||||
<label>Canonical URL<input id="seoCanonical" type="url"></label>
|
||||
<label>Open Graph image<input id="seoImage" type="text" placeholder="/uploads/image.jpg"></label>
|
||||
<label><input id="seoNoIndex" type="checkbox"> Ask search engines not to index this page</label>
|
||||
<button type="submit">Apply settings</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Future publication time is entered locally and submitted as an absolute timestamp. -->
|
||||
<div id="scheduleDialog" class="cms-dialog" title="Schedule Publication">
|
||||
<form id="scheduleForm">
|
||||
<label>Publish date and time<input id="publishAt" type="datetime-local" required></label>
|
||||
<button type="submit">Schedule</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Results from the browser-side authoring accessibility checks. -->
|
||||
<div id="accessibilityDialog" class="cms-dialog" title="Accessibility Check"><div id="accessibilityResults"></div></div>
|
||||
|
||||
<!-- Site-wide shared-region registry and propagation form. -->
|
||||
<div id="sharedDialog" class="cms-dialog" title="Shared Content">
|
||||
<div id="sharedList"></div>
|
||||
<form id="sharedForm">
|
||||
<label>Block name<input id="sharedKey" type="text" pattern="[A-Za-z0-9_-]+" required></label>
|
||||
<label>HTML content<textarea id="sharedContent" rows="8" required></textarea></label>
|
||||
<button type="submit">Save and update every page</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Line-oriented menu editor supporting an optional parent label for nesting. -->
|
||||
<div id="menusDialog" class="cms-dialog" title="Navigation Menus">
|
||||
<div id="menuList"></div>
|
||||
<p class="scan-note"><button id="menuScanButton" type="button">Scan site for new navigation</button> Finds navigation added to any page since the last scan.</p>
|
||||
<form id="menuForm" hidden>
|
||||
<h3 class="dialog-section">Editing menu: <code id="menuKey"></code></h3>
|
||||
<input id="menuName" type="hidden">
|
||||
<label>Menu name<input id="menuTitle" type="text" maxlength="60" placeholder="Display name"></label>
|
||||
<label>One item per line: Label | URL | Optional parent label<textarea id="menuItems" rows="9" placeholder="Home | / About | /about.html Team | /team.html | About" required></textarea></label>
|
||||
<button type="submit">Save menu</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Own account (everyone), role descriptions, and account management (administrators only). -->
|
||||
<div id="usersDialog" class="cms-dialog" title="Users">
|
||||
<h3 class="dialog-section">My account</h3>
|
||||
<form id="profileForm" autocomplete="off">
|
||||
<label>Login name <span class="scan-note">(used to sign in; set by an administrator)</span><input id="profileLogin" type="text" readonly></label>
|
||||
<label>Display name<input id="profileName" type="text" maxlength="80" required autocomplete="name"></label>
|
||||
<label>Email address <span class="scan-note">(contact details only; not used to sign in)</span><input id="profileEmail" type="email" maxlength="254" autocomplete="email"></label>
|
||||
<label>Current password <span class="scan-note">(only needed to change the email address)</span><input id="profileCurrent" type="password" maxlength="4096" autocomplete="current-password"></label>
|
||||
<button type="submit">Save details</button>
|
||||
</form>
|
||||
<form id="passwordForm" autocomplete="off">
|
||||
<h3 class="dialog-section">Change password</h3>
|
||||
<label>Current password<input id="pwCurrent" type="password" maxlength="4096" required autocomplete="current-password"></label>
|
||||
<label>New password <span class="scan-note">(12 to 72 characters)</span><input id="pwNew" type="password" minlength="12" maxlength="72" required autocomplete="new-password"></label>
|
||||
<label>Confirm new password<input id="pwConfirm" type="password" minlength="12" maxlength="72" required autocomplete="new-password"></label>
|
||||
<button type="submit">Change password</button>
|
||||
</form>
|
||||
<p id="passwordManaged" class="scan-note" hidden>Your password is managed in the site configuration (config.local.php) and cannot be changed here.</p>
|
||||
|
||||
<h3 class="dialog-section">Roles</h3>
|
||||
<div id="rolesList"></div>
|
||||
|
||||
<div id="userAdmin" class="manage-only">
|
||||
<h3 class="dialog-section">User accounts</h3>
|
||||
<div id="userList"></div>
|
||||
<p class="scan-note"><button id="userAddButton" type="button">Add user</button> <button id="userInviteButton" type="button">Invite user</button> Accounts marked Config are managed in config.local.php and cannot be changed here.</p>
|
||||
<form id="userForm" autocomplete="off" hidden>
|
||||
<h3 class="dialog-section" id="userFormTitle"></h3>
|
||||
<input id="userExisting" type="hidden">
|
||||
<label id="userUsernameRow">Login name <span class="scan-note">(3 to 32 letters, numbers, . - _)</span><input id="userUsername" type="text" maxlength="32" autocomplete="off"></label>
|
||||
<label>Display name<input id="userName" type="text" maxlength="80" required autocomplete="off"></label>
|
||||
<label>Email address <span class="scan-note">(contact details only; not used to sign in)</span><input id="userEmail" type="email" maxlength="254" autocomplete="off"></label>
|
||||
<label>Role<select id="userRole"><option value="editor">Editor</option><option value="administrator">Administrator</option></select></label>
|
||||
<label id="userPasswordRow">Password <span class="scan-note" id="userPasswordNote"></span><input id="userPassword" type="password" maxlength="72" autocomplete="new-password"></label>
|
||||
<label>Your password <span class="scan-note">(to confirm this change)</span><input id="userConfirm" type="password" maxlength="4096" required autocomplete="current-password"></label>
|
||||
<button type="submit" id="userSubmit">Save user</button> <button type="button" id="userCancel">Cancel</button>
|
||||
</form>
|
||||
<form id="confirmBox" autocomplete="off" hidden>
|
||||
<h3 class="dialog-section" id="confirmTitle"></h3>
|
||||
<label>Your password<input id="confirmPassword" type="password" maxlength="4096" required autocomplete="current-password"></label>
|
||||
<button type="submit">Confirm</button> <button type="button" id="confirmCancel">Cancel</button>
|
||||
</form>
|
||||
<div id="inviteResult" hidden>
|
||||
<h3 class="dialog-section">Invitation link</h3>
|
||||
<p class="scan-note">Send this link to the person. It is shown only once, works once, and expires in 7 days. Their login name is <strong id="inviteLogin"></strong>.</p>
|
||||
<input id="inviteLink" type="text" readonly> <button type="button" id="inviteCopy">Copy link</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Whole-site analysis with one-click tagging of content regions, images, and SEO tags. Administrators only. -->
|
||||
<div id="siteScanDialog" class="cms-dialog" title="Site Scan">
|
||||
<div id="siteScanProgress" hidden>
|
||||
<progress id="scanProgressBar" max="100" value="0"></progress>
|
||||
<div id="scanProgressText" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
<div id="siteScanSummary"></div>
|
||||
<fieldset id="siteScanOptions">
|
||||
<legend>Changes to make</legend>
|
||||
<label><input type="checkbox" id="scanContent" checked> Make content areas editable</label>
|
||||
<label><input type="checkbox" id="scanImages" checked> Make images editable</label>
|
||||
<label><input type="checkbox" id="scanSeo" checked> Add missing SEO tags (never overwrites existing ones)</label>
|
||||
<label><input type="checkbox" id="scanMenus" checked> Detect navigation menus (fills Navigation Menus; never overwrites a saved menu)</label>
|
||||
</fieldset>
|
||||
<div id="siteScanPages"></div>
|
||||
<p class="scan-note">Each changed page keeps a revision, so this can be undone from Revision history.</p>
|
||||
<button id="siteScanApply" type="button">Apply to selected pages</button>
|
||||
</div>
|
||||
|
||||
<!-- Replace or describe an image that sits outside an editable region. -->
|
||||
<div id="imageDialog" class="cms-dialog" title="Edit Image">
|
||||
<div id="imagePicker" class="media-grid"></div>
|
||||
<form id="imageForm">
|
||||
<label>Image address<input id="imageSrc" type="text" required></label>
|
||||
<label>Alternative text<input id="imageAlt" type="text"></label>
|
||||
<button type="submit">Apply image</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/js/cms.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1442
-297
File diff suppressed because it is too large
Load Diff
+107
-34
@@ -1,71 +1,144 @@
|
||||
<?php
|
||||
// login.php
|
||||
/** Login form and authentication request handler. */
|
||||
|
||||
require_once "../config.php";
|
||||
|
||||
// Autoload classes (if not using Composer)
|
||||
spl_autoload_register(function ($class) {
|
||||
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
|
||||
require_once "../src/{$classPath}.php";
|
||||
});
|
||||
// Load account configuration before constructing authentication services.
|
||||
require_once __DIR__ . '/../bootstrap.php';
|
||||
|
||||
use NeoCMS\Authentication;
|
||||
use NeoCMS\LoginRateLimiter;
|
||||
use NeoCMS\Logger;
|
||||
use NeoCMS\SecurityHeaders;
|
||||
use NeoCMS\UserStore;
|
||||
|
||||
// Initialise error message variable
|
||||
// Keep one escaped, user-facing error message for the rendered form.
|
||||
$error = '';
|
||||
|
||||
// Instantiate the Authentication and Logger classes
|
||||
$authentication = new Authentication($config['authentication'] ?? []);
|
||||
$logger = new Logger($config['audit'] ?? true);
|
||||
// Authentication owns the session; the logger records successful and failed attempts.
|
||||
$authentication = new Authentication(...\NeoCMS\UserStore::authArgs($config));
|
||||
$logger = new Logger($config['audit'] ?? true, $config['security'] ?? []);
|
||||
$dataDirectory = (string) ($config['dataDirectory'] ?? (__DIR__ . '/../data'));
|
||||
$rateLimiter = new LoginRateLimiter($dataDirectory, $config['security'] ?? []);
|
||||
SecurityHeaders::html(false, isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null);
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$username = $_POST['username'] ?? '';
|
||||
$password = $_POST['password'] ?? '';
|
||||
// An invitation link (?invite=TOKEN) shows a set-password form instead of the login form.
|
||||
$users = UserStore::fromConfig($config);
|
||||
$invite = is_string($_POST['invite'] ?? null) ? $_POST['invite'] : (is_string($_GET['invite'] ?? null) ? $_GET['invite'] : '');
|
||||
$invite = preg_match('/^[a-f0-9]{64}$/', $invite) ? $invite : '';
|
||||
|
||||
if ($authentication->login($username, $password)) {
|
||||
// Log successful login
|
||||
// GET displays the form, while POST validates its token and submitted credentials.
|
||||
$maxLoginRequestBytes = 16 * 1024;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > $maxLoginRequestBytes) {
|
||||
http_response_code(413);
|
||||
$error = 'Login request is too large';
|
||||
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrfToken = $_POST['csrf_token'] ?? null;
|
||||
if (!$authentication->isValidCsrfToken($csrfToken)) {
|
||||
http_response_code(400);
|
||||
$logger->write(
|
||||
"User login for $username was successful from {$_SERVER['REMOTE_ADDR']}",
|
||||
$username
|
||||
'User login attempt rejected due to invalid CSRF token from ' . (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'),
|
||||
'anonymous'
|
||||
);
|
||||
|
||||
// Redirect to the CMS dashboard or desired page
|
||||
header("Location: /cms/");
|
||||
exit("Well done, champ! You got this!! <3");
|
||||
$error = "Invalid request token";
|
||||
} elseif ($invite !== '' && isset($_POST['invite'])) {
|
||||
$address = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||||
$password = is_string($_POST['password'] ?? null) ? $_POST['password'] : '';
|
||||
$confirm = is_string($_POST['confirm'] ?? null) ? $_POST['confirm'] : '';
|
||||
if (($retryAfter = $rateLimiter->retryAfter($address, 'invite')) > 0) {
|
||||
http_response_code(429);
|
||||
header('Retry-After: ' . $retryAfter);
|
||||
$error = 'Too many attempts. Please try again later.';
|
||||
} elseif (!hash_equals($password, $confirm)) {
|
||||
$error = 'The passwords do not match';
|
||||
} else {
|
||||
// Log failed login attempt
|
||||
try {
|
||||
$username = $users->acceptInvite($invite, $password);
|
||||
$rateLimiter->clear($address, 'invite');
|
||||
$authentication = new Authentication(...UserStore::authArgs($config));
|
||||
$authentication->login($username, $password);
|
||||
$logger->write("Invitation accepted for {$username} from {$address}", $username);
|
||||
header('Location: ' . $config['basePath'] . '/cms/');
|
||||
exit;
|
||||
} catch (\RuntimeException $exception) {
|
||||
$rateLimiter->recordFailure($address, 'invite');
|
||||
$logger->write('Invitation rejected from ' . $address, 'anonymous');
|
||||
$error = $exception->getMessage();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$username = is_string($_POST['username'] ?? null) ? trim($_POST['username']) : '';
|
||||
$password = is_string($_POST['password'] ?? null) ? $_POST['password'] : '';
|
||||
$address = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||||
|
||||
if (strlen($username) > 128 || strlen($password) > 4096) {
|
||||
http_response_code(400);
|
||||
$error = 'Invalid login name or password';
|
||||
} elseif (($retryAfter = $rateLimiter->retryAfter($address, $username)) > 0) {
|
||||
http_response_code(429);
|
||||
header('Retry-After: ' . $retryAfter);
|
||||
$logger->write("Login throttled from {$address}", $username ?: 'anonymous');
|
||||
$error = 'Too many login attempts. Please try again later.';
|
||||
} elseif ($authentication->login($username, $password)) {
|
||||
$rateLimiter->clear($address, $username);
|
||||
$users->rehashIfNeeded($username, $password);
|
||||
|
||||
// Record the source address before entering the privileged interface.
|
||||
$logger->write(
|
||||
"User login for $username was denied due to incorrect credentials from {$_SERVER['REMOTE_ADDR']}",
|
||||
"User login for {$username} was successful from {$address}",
|
||||
$username
|
||||
);
|
||||
|
||||
$error = "Invalid username or password";
|
||||
// Redirect after POST so browser refreshes do not resubmit credentials.
|
||||
header("Location: " . $config['basePath'] . "/cms/");
|
||||
exit;
|
||||
} else {
|
||||
$rateLimiter->recordFailure($address, $username);
|
||||
// Record denied attempts without logging the supplied password. Obviously.
|
||||
$logger->write(
|
||||
"User login for {$username} was denied due to incorrect credentials from {$address}",
|
||||
$username
|
||||
);
|
||||
|
||||
$error = "Invalid login name or password";
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en-gb">
|
||||
<head>
|
||||
<title>NeoCMS Login</title>
|
||||
<link rel="stylesheet" href="/cms/css/login.css"/>
|
||||
<link rel="stylesheet" href="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/css/login.css"/>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- The compact login card is intentionally independent of the heavier administration UI. -->
|
||||
<div class="login-container">
|
||||
<img class="logo" src="/cms/img/loginlogo.png" alt="NeoCMS logo"/>
|
||||
<h2>NeoCMS Login</h2>
|
||||
<img class="logo" src="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/img/loginlogo.png" alt="NeoCMS logo"/>
|
||||
<h2><?php echo $invite !== '' ? 'Set your password' : 'NeoCMS Login'; ?></h2>
|
||||
<?php if (!empty($error)): ?>
|
||||
<div class="error-message"><?php echo htmlspecialchars($error); ?></div>
|
||||
<div class="error-message"><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></div>
|
||||
<?php endif; ?>
|
||||
<!-- CSRF protection applies to login too, preventing forced authentication state changes. -->
|
||||
<form method="post">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($authentication->getCsrfToken(), ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<?php if ($invite !== ''): ?>
|
||||
<input type="hidden" name="invite" value="<?php echo htmlspecialchars($invite, ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<label for="password">New password (12 to 72 characters)</label>
|
||||
<input type="password" id="password" name="password" required minlength="12" maxlength="72" autocomplete="new-password" autofocus>
|
||||
|
||||
<label for="confirm">Confirm password</label>
|
||||
<input type="password" id="confirm" name="confirm" required minlength="12" maxlength="72" autocomplete="new-password">
|
||||
|
||||
<button type="submit">Set password and sign in</button>
|
||||
<?php else: ?>
|
||||
<label for="username">Login name</label>
|
||||
<input type="text" id="username" name="username" required autofocus autocomplete="username">
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
|
||||
<button type="submit">Log In</button>
|
||||
<?php endif; ?>
|
||||
<p class="shamelessPlug">NeoCMS ©<?php echo date('Y'); ?> Andy Dixon</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Audit entries contain usernames, addresses, and operational detail; keep them private.
|
||||
Require all denied
|
||||
+3
-1
@@ -1,2 +1,4 @@
|
||||
<?php
|
||||
header("Location: /cms/");
|
||||
// Audit logs are not a public attraction; return to the authenticated CMS instead.
|
||||
http_response_code(404);
|
||||
header("Location: ../");
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
/**
|
||||
* Command-line worker for scheduled publications.
|
||||
*
|
||||
* Run this file from cron once per minute. It is intentionally unavailable over HTTP: scheduled
|
||||
* publishing is useful, while a public button marked "publish everything due" is rather less so.
|
||||
*/
|
||||
|
||||
// Load the same configuration and autoloader used by the interactive CMS.
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
// Refuse web requests even if the web server accidentally exposes this script.
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Cron may omit DOCUMENT_ROOT, so derive it from the CMS directory when necessary.
|
||||
if (empty($_SERVER['DOCUMENT_ROOT'])) {
|
||||
$_SERVER['DOCUMENT_ROOT'] = dirname(__DIR__);
|
||||
}
|
||||
|
||||
// Publish every due job and print a concise result suitable for a cron log.
|
||||
$controller = new NeoCMS\CMSController($config ?? []);
|
||||
$count = $controller->publishScheduled();
|
||||
echo "Published {$count} scheduled page(s).\n";
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/** Records one operational event in both the Dashboard activity feed and the daily audit log. */
|
||||
final class Activity
|
||||
{
|
||||
public function __construct(private FileStore $store, private Logger $logger)
|
||||
{
|
||||
}
|
||||
|
||||
public function record(string $user, string $action, string $target): void
|
||||
{
|
||||
$entry = ['created' => date(DATE_ATOM), 'user' => $user, 'action' => $action, 'target' => $target];
|
||||
// Retaining the latest 250 entries keeps the dashboard useful without growing forever.
|
||||
$this->store->update('activity', function (array $entries) use ($entry) {
|
||||
$entries[] = $entry;
|
||||
return array_slice($entries, -250);
|
||||
});
|
||||
$this->logger->write($action . ': ' . $target, $user);
|
||||
}
|
||||
}
|
||||
@@ -2,58 +2,267 @@
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/**
|
||||
* Manages session authentication, role capabilities, and CSRF tokens.
|
||||
*
|
||||
* Credentials remain configuration-backed to preserve NeoCMS's database-free design. Only hashes
|
||||
* created by password_hash() are accepted; plaintext credentials are rejected.
|
||||
*/
|
||||
class Authentication
|
||||
{
|
||||
private $credentials;
|
||||
/** Valid bcrypt hash used solely to equalise unknown-user password verification work. */
|
||||
private const DUMMY_PASSWORD_HASH = '$2y$10$NaiFDtsxcQzcxAiQrTmEPuDde8sDsdpjAlSgoiYwAvdnF5Cwytute';
|
||||
|
||||
public function __construct(array $credentials)
|
||||
/** Map of usernames to password hashes. */
|
||||
private array $credentials;
|
||||
|
||||
/** Map of usernames to editor or administrator roles (the legacy publisher role counts as editor). */
|
||||
private array $roles;
|
||||
|
||||
/** Maximum permitted inactivity for an authenticated session. */
|
||||
private int $idleTimeout;
|
||||
|
||||
/** Maximum total lifetime of an authenticated session. */
|
||||
private int $absoluteTimeout;
|
||||
|
||||
/** URL path the session cookie is scoped to; the CMS directory, which moves when the site is in a subfolder. */
|
||||
private string $cookiePath;
|
||||
|
||||
/** Optional explicit Secure-cookie setting for TLS-terminating reverse proxies. */
|
||||
private ?bool $cookieSecure;
|
||||
|
||||
/** Initialise secure session handling and retain the configured account maps. */
|
||||
public function __construct(array $credentials, array $roles = [], array $sessionOptions = [])
|
||||
{
|
||||
session_start();
|
||||
$this->credentials = $credentials;
|
||||
$this->roles = $roles;
|
||||
$this->idleTimeout = max(300, (int) ($sessionOptions['idleTimeout'] ?? 1800));
|
||||
$this->absoluteTimeout = max($this->idleTimeout, (int) ($sessionOptions['absoluteTimeout'] ?? 43200));
|
||||
$this->cookieSecure = isset($sessionOptions['cookieSecure']) ? (bool) $sessionOptions['cookieSecure'] : null;
|
||||
$this->cookiePath = (string) ($sessionOptions['cookiePath'] ?? '/cms');
|
||||
$this->startSession();
|
||||
$this->enforceSessionLifetime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a user into the CMS
|
||||
* @param $username
|
||||
* @param $password
|
||||
* @return bool
|
||||
* Start the PHP session with conservative cookie settings.
|
||||
*
|
||||
* HTTPS cookies are marked Secure when the request is encrypted. SameSite=Lax protects common
|
||||
* cross-site request scenarios without making ordinary login redirects needlessly dramatic.
|
||||
*/
|
||||
public function login($username, $password)
|
||||
private function startSession(): void
|
||||
{
|
||||
if (!empty($this->credentials[$username]) && $this->credentials[$username] === $password) {
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
|
||||
$isHttps = $this->cookieSecure ?? (
|
||||
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (isset($_SERVER['SERVER_PORT']) && (int) $_SERVER['SERVER_PORT'] === 443)
|
||||
);
|
||||
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
ini_set('session.cookie_httponly', '1');
|
||||
ini_set('session.cookie_samesite', 'Lax');
|
||||
session_name('NEOCMSSESSID');
|
||||
|
||||
session_set_cookie_params([
|
||||
'httponly' => true,
|
||||
'path' => $this->cookiePath,
|
||||
'samesite' => 'Lax',
|
||||
'secure' => $isHttps,
|
||||
]);
|
||||
|
||||
session_start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a user and establish a fresh privileged session.
|
||||
*
|
||||
* @param mixed $username Submitted account name.
|
||||
* @param mixed $password Submitted plaintext password.
|
||||
*/
|
||||
public function login($username, $password): bool
|
||||
{
|
||||
$username = (string) $username;
|
||||
if ($this->credentialsAreValid($username, (string) $password)) {
|
||||
// Rotate the identifier at the privilege boundary to prevent session fixation.
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['loggedIn'] = true;
|
||||
$_SESSION['loggedInUser'] = $username;
|
||||
$_SESSION['role'] = UserStore::normaliseRole($this->roles[$username] ?? 'editor');
|
||||
$_SESSION['credentialFingerprint'] = hash('sha256', (string) $this->credentials[$username]);
|
||||
$_SESSION['csrfToken'] = bin2hex(random_bytes(32));
|
||||
$_SESSION['authenticatedAt'] = time();
|
||||
$_SESSION['lastActivityAt'] = time();
|
||||
$_SESSION['lastRegeneratedAt'] = time();
|
||||
return true;
|
||||
}
|
||||
|
||||
$_SESSION['loggedIn'] = false;
|
||||
unset(
|
||||
$_SESSION['loggedIn'],
|
||||
$_SESSION['loggedInUser'],
|
||||
$_SESSION['role'],
|
||||
$_SESSION['credentialFingerprint'],
|
||||
$_SESSION['authenticatedAt'],
|
||||
$_SESSION['lastActivityAt'],
|
||||
$_SESSION['lastRegeneratedAt']
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user has logged in
|
||||
* @return bool
|
||||
*/
|
||||
/** Return whether the current session belongs to an authenticated user. */
|
||||
public function isLoggedIn(): bool
|
||||
{
|
||||
return $_SESSION['loggedIn'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user logged in
|
||||
* @return string
|
||||
*/
|
||||
/** Return the current username, or a safe label when no user is authenticated. */
|
||||
public function getLoggedInUser(): string
|
||||
{
|
||||
return $_SESSION['loggedInUser'] ?? 'Not logged in';
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the session
|
||||
* @return void
|
||||
*/
|
||||
public function logout()
|
||||
/** Return the session role, defaulting unauthorised sessions to the least powerful role. */
|
||||
public function getRole(): string
|
||||
{
|
||||
$username = $this->getLoggedInUser();
|
||||
$role = UserStore::normaliseRole($this->roles[$username] ?? 'editor');
|
||||
$_SESSION['role'] = $role;
|
||||
return $role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the current role includes a named capability.
|
||||
*
|
||||
* Capability checks live here so controllers and upload handlers share one policy table.
|
||||
*/
|
||||
public function can(string $capability): bool
|
||||
{
|
||||
$permissions = [
|
||||
'editor' => ['draft', 'upload', 'publish', 'schedule'],
|
||||
'administrator' => ['draft', 'upload', 'publish', 'schedule', 'manage'],
|
||||
];
|
||||
|
||||
return in_array($capability, $permissions[$this->getRole()] ?? [], true);
|
||||
}
|
||||
|
||||
/** Re-bind the current session to a changed credential so the user stays signed in while their other sessions end. */
|
||||
public function renewSession(string $credentialHash): void
|
||||
{
|
||||
$_SESSION['credentialFingerprint'] = hash('sha256', $credentialHash);
|
||||
if (session_status() === PHP_SESSION_ACTIVE && !headers_sent()) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
$_SESSION['lastRegeneratedAt'] = time();
|
||||
}
|
||||
|
||||
/** Verify the signed-in user's password (for re-authentication before sensitive actions). */
|
||||
public function verifyCurrentPassword(string $password): bool
|
||||
{
|
||||
return $this->credentialsAreValid($this->getLoggedInUser(), $password);
|
||||
}
|
||||
|
||||
/** Return the session's CSRF token, generating a cryptographically random token when absent. */
|
||||
public function getCsrfToken(): string
|
||||
{
|
||||
if (empty($_SESSION['csrfToken'])) {
|
||||
$_SESSION['csrfToken'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
return $_SESSION['csrfToken'];
|
||||
}
|
||||
|
||||
/** Compare a submitted CSRF token in constant time to avoid leaking useful timing information. */
|
||||
public function isValidCsrfToken(?string $token): bool
|
||||
{
|
||||
if (!is_string($token) || $token === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($this->getCsrfToken(), $token);
|
||||
}
|
||||
|
||||
/** Clear session data, expire its cookie, and destroy the server-side session. */
|
||||
public function logout(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', [
|
||||
'expires' => time() - 42000,
|
||||
'path' => $params['path'],
|
||||
'domain' => $params['domain'],
|
||||
'secure' => $params['secure'],
|
||||
'httponly' => $params['httponly'],
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
}
|
||||
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate submitted credentials against a password_hash() value.
|
||||
*/
|
||||
private function credentialsAreValid(string $username, string $password): bool
|
||||
{
|
||||
$storedCredential = $this->credentials[$username] ?? null;
|
||||
$passwordInfo = is_string($storedCredential) ? password_get_info($storedCredential) : [];
|
||||
$configuredHashIsValid = is_string($storedCredential)
|
||||
&& $storedCredential !== ''
|
||||
&& ($passwordInfo['algoName'] ?? 'unknown') !== 'unknown';
|
||||
$verificationHash = $configuredHashIsValid ? $storedCredential : self::DUMMY_PASSWORD_HASH;
|
||||
$verified = password_verify($password, $verificationHash);
|
||||
return $configuredHashIsValid && $verified;
|
||||
}
|
||||
|
||||
/** Expire stale sessions and periodically rotate active session identifiers. */
|
||||
private function enforceSessionLifetime(): void
|
||||
{
|
||||
if (empty($_SESSION['loggedIn'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$username = is_string($_SESSION['loggedInUser'] ?? null) ? $_SESSION['loggedInUser'] : '';
|
||||
$storedCredential = $this->credentials[$username] ?? null;
|
||||
$passwordInfo = is_string($storedCredential) ? password_get_info($storedCredential) : [];
|
||||
if (!is_string($storedCredential) || ($passwordInfo['algoName'] ?? 'unknown') === 'unknown') {
|
||||
$this->invalidateSession();
|
||||
return;
|
||||
}
|
||||
$fingerprint = hash('sha256', $storedCredential);
|
||||
if (isset($_SESSION['credentialFingerprint']) && !hash_equals((string) $_SESSION['credentialFingerprint'], $fingerprint)) {
|
||||
$this->invalidateSession();
|
||||
return;
|
||||
}
|
||||
$_SESSION['credentialFingerprint'] = $fingerprint;
|
||||
|
||||
$_SESSION['authenticatedAt'] = (int) ($_SESSION['authenticatedAt'] ?? $now);
|
||||
$_SESSION['lastActivityAt'] = (int) ($_SESSION['lastActivityAt'] ?? $now);
|
||||
$_SESSION['lastRegeneratedAt'] = (int) ($_SESSION['lastRegeneratedAt'] ?? $now);
|
||||
|
||||
$idleExpired = $now - $_SESSION['lastActivityAt'] > $this->idleTimeout;
|
||||
$absoluteExpired = $now - $_SESSION['authenticatedAt'] > $this->absoluteTimeout;
|
||||
if ($idleExpired || $absoluteExpired) {
|
||||
$this->invalidateSession();
|
||||
return;
|
||||
}
|
||||
|
||||
if ($now - $_SESSION['lastRegeneratedAt'] > 900) {
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['lastRegeneratedAt'] = $now;
|
||||
}
|
||||
$_SESSION['lastActivityAt'] = $now;
|
||||
}
|
||||
|
||||
/** Clear privileged session state and rotate the identifier after expiry or account revocation. */
|
||||
private function invalidateSession(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
}
|
||||
|
||||
+1364
-187
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/** Pure HTML transformations for shared regions, generated menus, and editable-region detection. */
|
||||
final class ContentDom
|
||||
{
|
||||
/** Return each data-neo-shared region's inner HTML, keyed by its shared name. */
|
||||
public static function sharedBlocks(string $html): array
|
||||
{
|
||||
$found = [];
|
||||
foreach ((new \DOMXPath(self::load($html)))->query('//*[@data-neo-shared]') as $node) {
|
||||
$key = $node->getAttribute('data-neo-shared');
|
||||
if ($key !== '') {
|
||||
$found[$key] = self::innerHtml($node);
|
||||
}
|
||||
}
|
||||
return $found;
|
||||
}
|
||||
|
||||
/** Replace every region marked data-neo-shared="$key"; null when the page has none. */
|
||||
public static function withShared(string $html, string $key, string $content): ?string
|
||||
{
|
||||
if (!str_contains($html, 'data-neo-shared')) {
|
||||
return null;
|
||||
}
|
||||
$dom = self::load($html);
|
||||
// $key is restricted to [a-zA-Z0-9_-] by the caller, so it cannot break out of the XPath string.
|
||||
$nodes = (new \DOMXPath($dom))->query('//*[@data-neo-shared="' . $key . '"]');
|
||||
if ($nodes->length === 0) {
|
||||
return null;
|
||||
}
|
||||
foreach ($nodes as $node) {
|
||||
self::replaceInnerHtml($dom, $node, $content);
|
||||
}
|
||||
return $dom->saveHTML();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the list inside every element marked data-neo-menu="$name", keeping the element's own attributes
|
||||
* and every other byte of the page. Null when nothing needs to change.
|
||||
*/
|
||||
public static function withMenu(string $html, string $name, array $items, string $uri = '', string $basePath = ''): ?string
|
||||
{
|
||||
if (!str_contains($html, 'data-neo-menu')) {
|
||||
return null;
|
||||
}
|
||||
$list = self::menuList($items, $uri, $basePath);
|
||||
$flat = fn(string $s) => preg_replace('/>\s+</', '><', trim($s));
|
||||
$edits = [];
|
||||
foreach (SiteAnalyser::navs($html) as $nav) {
|
||||
if ($nav['menu'] !== $name || $nav['close'] === null) {
|
||||
continue;
|
||||
}
|
||||
if ($flat(substr($html, $nav['end'], $nav['close'] - $nav['end'])) !== $flat($list)) {
|
||||
$edits[] = [$nav['end'], $nav['close'] - $nav['end']];
|
||||
}
|
||||
}
|
||||
rsort($edits);
|
||||
foreach ($edits as [$offset, $length]) {
|
||||
$html = substr($html, 0, $offset) . $list . substr($html, $offset + $length);
|
||||
}
|
||||
return $edits ? $html : null;
|
||||
}
|
||||
|
||||
/** A menu as a standalone <nav> (the list itself comes from menuList). */
|
||||
public static function renderMenu(string $name, array $items): string
|
||||
{
|
||||
return '<nav data-neo-menu="' . htmlspecialchars($name, ENT_QUOTES) . '">' . self::menuList($items) . '</nav>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a nested, escaped list from flat parent-labelled items, marking the link to the page being written.
|
||||
* Circular parent references are stopped by the ancestor list rather than pursued forever.
|
||||
*/
|
||||
public static function menuList(array $items, string $uri = '', string $basePath = ''): string
|
||||
{
|
||||
$children = [];
|
||||
foreach ($items as $item) {
|
||||
$children[$item['parent'] ?? ''][] = $item;
|
||||
}
|
||||
$render = function (string $parent, array $ancestors = []) use (&$render, $children, $uri, $basePath): string {
|
||||
if (empty($children[$parent])) {
|
||||
return '';
|
||||
}
|
||||
$html = '<ul>';
|
||||
foreach ($children[$parent] as $item) {
|
||||
$label = htmlspecialchars($item['label'], ENT_QUOTES);
|
||||
$url = htmlspecialchars($item['url'], ENT_QUOTES);
|
||||
$current = $uri !== '' && self::isPage($item['url'], $uri, $basePath) ? ' aria-current="page"' : '';
|
||||
$nested = in_array($item['label'], $ancestors, true) ? '' : $render($item['label'], array_merge($ancestors, [$item['label']]));
|
||||
$html .= '<li><a href="' . $url . '"' . $current . '>' . $label . '</a>' . $nested . '</li>';
|
||||
}
|
||||
return $html . '</ul>';
|
||||
};
|
||||
return $render('');
|
||||
}
|
||||
|
||||
/** Turn a page link into a site path (relative links resolved against the page, query/fragment kept); null for external or fragment-only links. */
|
||||
public static function resolveLink(string $url, string $pageUri): ?string
|
||||
{
|
||||
$url = trim($url);
|
||||
$path = parse_url($url, PHP_URL_PATH);
|
||||
if ($url === '' || $url[0] === '#' || preg_match('#^([a-z][a-z0-9+.-]*:|//)#i', $url) || !is_string($path) || $path === '') {
|
||||
return null;
|
||||
}
|
||||
if ($path[0] !== '/') {
|
||||
$path = rtrim(str_replace('\\', '/', dirname($pageUri)), '/') . '/' . $path;
|
||||
}
|
||||
$out = [];
|
||||
foreach (explode('/', $path) as $segment) {
|
||||
if ($segment === '' || $segment === '.') {
|
||||
continue;
|
||||
}
|
||||
$segment === '..' ? array_pop($out) : $out[] = $segment;
|
||||
}
|
||||
return '/' . implode('/', $out) . (str_ends_with($path, '/') && $out ? '/' : '') . substr($url, strcspn($url, '?#'));
|
||||
}
|
||||
|
||||
/** Whether a menu link points at the page with this URI (a folder link means its index.html; a subfolder install's prefix is ignored). */
|
||||
private static function isPage(string $url, string $uri, string $basePath): bool
|
||||
{
|
||||
$resolved = self::resolveLink($url, $uri);
|
||||
if ($resolved === null) {
|
||||
return false;
|
||||
}
|
||||
$index = fn(string $p) => str_ends_with($p, '/') ? $p . 'index.html' : $p;
|
||||
$path = $index((string) parse_url($resolved, PHP_URL_PATH));
|
||||
$uri = $index($uri);
|
||||
return $path === $uri || ($basePath !== '' && $path === $basePath . $uri);
|
||||
}
|
||||
/** Determine whether a document contains at least one element carrying exactly this CSS class token. */
|
||||
public static function hasClass(string $html, string $class): bool
|
||||
{
|
||||
$dom = new \DOMDocument();
|
||||
@$dom->loadHTML($html);
|
||||
// Class-token matching avoids treating "editable-extra" as though it were "editable".
|
||||
$query = '//*[contains(concat(" ", normalize-space(@class), " "), " ' . $class . ' ")]';
|
||||
return (new \DOMXPath($dom))->query($query)->length > 0;
|
||||
}
|
||||
|
||||
/** Parse HTML as UTF-8 (libxml assumes Latin-1 otherwise) without keeping the encoding hint in the output. */
|
||||
private static function load(string $html): \DOMDocument
|
||||
{
|
||||
$dom = new \DOMDocument();
|
||||
@$dom->loadHTML('<?xml encoding="UTF-8">' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
foreach (iterator_to_array($dom->childNodes) as $node) {
|
||||
if ($node->nodeType === XML_PI_NODE) {
|
||||
$dom->removeChild($node);
|
||||
}
|
||||
}
|
||||
return $dom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a DOM node's children with an HTML fragment.
|
||||
*
|
||||
* DOMDocument parses forgiving HTML here rather than strict XML, allowing ordinary authoring
|
||||
* fragments such as <br> without demanding that editors suddenly become XML librarians.
|
||||
*/
|
||||
private static function replaceInnerHtml(\DOMDocument $dom, \DOMNode $node, string $html): void
|
||||
{
|
||||
while ($node->firstChild) {
|
||||
$node->removeChild($node->firstChild);
|
||||
}
|
||||
$wrapper = self::load('<div id="neo-fragment">' . $html . '</div>')->getElementById('neo-fragment');
|
||||
if (!$wrapper) {
|
||||
$node->appendChild($dom->createTextNode($html));
|
||||
return;
|
||||
}
|
||||
foreach (iterator_to_array($wrapper->childNodes) as $child) {
|
||||
$node->appendChild($dom->importNode($child, true));
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialise only the children of a DOM node, excluding the node's own wrapper tag. */
|
||||
private static function innerHtml(\DOMNode $node): string
|
||||
{
|
||||
$html = '';
|
||||
foreach ($node->childNodes as $child) {
|
||||
$html .= $node->ownerDocument->saveHTML($child);
|
||||
}
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/**
|
||||
* Persists NeoCMS metadata as human-readable JSON and provides managed subdirectories.
|
||||
*
|
||||
* Public pages remain ordinary HTML files. This store contains only the supporting cast: drafts,
|
||||
* revisions, schedules, menus, media metadata, and activity. No database server need feel left out.
|
||||
*/
|
||||
final class FileStore
|
||||
{
|
||||
/** Absolute data-directory path, always ending with the platform separator. */
|
||||
private string $root;
|
||||
|
||||
/**
|
||||
* Prepare the configured data directory.
|
||||
*
|
||||
* @param string $root Absolute or project-relative storage path.
|
||||
*/
|
||||
public function __construct(string $root)
|
||||
{
|
||||
$this->root = rtrim($root, '/\\') . DIRECTORY_SEPARATOR;
|
||||
$this->ensureDirectory($this->root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a named JSON document, returning the supplied default when it does not yet exist.
|
||||
* Invalid JSON is treated as absent data so callers always receive the promised array shape.
|
||||
*
|
||||
* @param string $name Logical document name without the .json suffix.
|
||||
* @param array $default Value returned for a missing or unreadable document.
|
||||
* @return array Decoded document contents.
|
||||
*/
|
||||
public function read(string $name, array $default = []): array
|
||||
{
|
||||
$path = $this->jsonPath($name);
|
||||
if (!is_file($path)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$value = json_decode((string) file_get_contents($path), true);
|
||||
return is_array($value) ? $value : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically replace a named JSON document.
|
||||
*
|
||||
* Data is written to a temporary neighbour and renamed only after the write succeeds. Readers
|
||||
* therefore see either the old document or the complete new one, never half a JSON sandwich.
|
||||
*
|
||||
* @param string $name Logical document name without the .json suffix.
|
||||
* @param array $value Serializable document contents.
|
||||
*/
|
||||
public function write(string $name, array $value): void
|
||||
{
|
||||
$path = $this->jsonPath($name);
|
||||
$temporary = $path . '.' . bin2hex(random_bytes(6)) . '.tmp';
|
||||
$json = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false || file_put_contents($temporary, $json . "\n", LOCK_EX) === false) {
|
||||
throw new \RuntimeException('Unable to write CMS data');
|
||||
}
|
||||
@chmod($temporary, 0600);
|
||||
if (!rename($temporary, $path)) {
|
||||
@unlink($temporary);
|
||||
throw new \RuntimeException('Unable to replace CMS data');
|
||||
}
|
||||
@chmod($path, 0600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read, modify, and write a JSON document under an exclusive lock so concurrent requests cannot lose updates.
|
||||
*
|
||||
* @param callable $fn Receives the current array and returns the array to store.
|
||||
* @return array The stored document.
|
||||
*/
|
||||
public function update(string $name, callable $fn, array $default = []): array
|
||||
{
|
||||
$lockPath = $this->directory('locks') . preg_replace('/[^a-zA-Z0-9_.-]/', '', $name) . '.lock';
|
||||
$lock = fopen($lockPath, 'c+');
|
||||
if ($lock === false || !flock($lock, LOCK_EX)) {
|
||||
if (is_resource($lock)) {
|
||||
fclose($lock);
|
||||
}
|
||||
throw new \RuntimeException('Unable to lock CMS data');
|
||||
}
|
||||
try {
|
||||
$value = $fn($this->read($name, $default));
|
||||
$this->write($name, $value);
|
||||
return $value;
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically write a private content file beneath a managed subdirectory. */
|
||||
public function writePrivateFile(string $directory, string $filename, string $content): string
|
||||
{
|
||||
$target = $this->directory($directory) . basename($filename);
|
||||
$temporary = $target . '.' . bin2hex(random_bytes(6)) . '.tmp';
|
||||
if (file_put_contents($temporary, $content, LOCK_EX) === false) {
|
||||
throw new \RuntimeException('Unable to write private CMS file');
|
||||
}
|
||||
@chmod($temporary, 0600);
|
||||
if (!rename($temporary, $target)) {
|
||||
@unlink($temporary);
|
||||
throw new \RuntimeException('Unable to replace private CMS file');
|
||||
}
|
||||
@chmod($target, 0600);
|
||||
return $target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a managed subdirectory, creating it when first required.
|
||||
*
|
||||
* @param string $name Relative directory name such as drafts or revisions.
|
||||
*/
|
||||
public function directory(string $name): string
|
||||
{
|
||||
$path = $this->root . trim($name, '/\\') . DIRECTORY_SEPARATOR;
|
||||
$this->ensureDirectory($path);
|
||||
return $path;
|
||||
}
|
||||
|
||||
/** Convert a logical document name into a safe JSON path beneath the data root. */
|
||||
private function jsonPath(string $name): string
|
||||
{
|
||||
return $this->root . preg_replace('/[^a-zA-Z0-9_.-]/', '', $name) . '.json';
|
||||
}
|
||||
|
||||
/** Create a directory recursively or fail with a useful application-level exception. */
|
||||
private function ensureDirectory(string $path): void
|
||||
{
|
||||
if (!is_dir($path) && !@mkdir($path, 0700, true) && !is_dir($path)) {
|
||||
throw new \RuntimeException('Unable to create CMS data directory');
|
||||
}
|
||||
@chmod($path, 0700);
|
||||
}
|
||||
}
|
||||
+86
-10
@@ -1,31 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/** Writes simple, daily, append-only audit logs for operational traceability. */
|
||||
class Logger
|
||||
{
|
||||
/** Whether audit writes are enabled by configuration. */
|
||||
private $auditEnabled;
|
||||
|
||||
/** Absolute directory containing the dated audit files. */
|
||||
private $logsDir;
|
||||
|
||||
public function __construct($auditEnabled)
|
||||
/** Maximum size of one active daily log before it is rotated. */
|
||||
private $maxFileBytes;
|
||||
|
||||
/** Number of days for which audit logs are retained. */
|
||||
private $retentionDays;
|
||||
|
||||
/**
|
||||
* Configure logging and ensure its destination exists.
|
||||
*
|
||||
* @param mixed $auditEnabled Truthy values enable audit output.
|
||||
* @param array<string, mixed> $security Security limits shared with the rest of the CMS.
|
||||
*/
|
||||
public function __construct($auditEnabled, array $security = [])
|
||||
{
|
||||
$this->auditEnabled = $auditEnabled;
|
||||
|
||||
// Conservative defaults prevent a forgotten audit directory from becoming its own archive service.
|
||||
$this->maxFileBytes = max(1024, (int) ($security['auditMaxFileBytes'] ?? 10 * 1024 * 1024));
|
||||
$this->retentionDays = max(1, (int) ($security['auditRetentionDays'] ?? 90));
|
||||
|
||||
$this->logsDir = __DIR__ . '/../../logs';
|
||||
$this->logsDir = rtrim($this->logsDir, '/\\') . DIRECTORY_SEPARATOR;
|
||||
|
||||
// Ensure the logs directory exists
|
||||
// Create the directory lazily so a fresh installation needs no ceremonial folder setup.
|
||||
if (!is_dir($this->logsDir)) {
|
||||
if (!mkdir($this->logsDir, 0755, true)) {
|
||||
if (!mkdir($this->logsDir, 0700, true)) {
|
||||
throw new \Exception('Failed to create logs directory: ' . $this->logsDir);
|
||||
}
|
||||
}
|
||||
@chmod($this->logsDir, 0700);
|
||||
|
||||
// Pruning scans every rotated log file; a Logger is built on every request, so do this
|
||||
// occasionally rather than on each one (the same probabilistic approach as PHP's own session GC).
|
||||
if ($this->auditEnabled && random_int(1, 100) === 1) {
|
||||
$this->pruneExpiredLogs();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an entry to the log
|
||||
* @param $message
|
||||
* @param $user
|
||||
* @return void
|
||||
* Append a timestamped user action to today's audit file.
|
||||
*
|
||||
* @param mixed $message Human-readable action description.
|
||||
* @param mixed $user Username responsible for the action.
|
||||
*/
|
||||
public function write($message, $user): void
|
||||
{
|
||||
@@ -33,14 +61,62 @@ class Logger
|
||||
return;
|
||||
}
|
||||
|
||||
// One file per day keeps inspection and rotation pleasantly unsurprising.
|
||||
$logFile = $this->logsDir . date('Y-m-d') . '-audit.txt';
|
||||
$this->rotateFullLog($logFile);
|
||||
$entry = sprintf(
|
||||
"[%s]\tUser: %s\t%s\n",
|
||||
date('Y-m-d H:i:s'),
|
||||
$user,
|
||||
trim($message)
|
||||
$this->normaliseLogField($user),
|
||||
$this->normaliseLogField($message)
|
||||
);
|
||||
|
||||
file_put_contents($logFile, $entry, FILE_APPEND);
|
||||
if (file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX) === false) {
|
||||
throw new \RuntimeException('Unable to write audit log');
|
||||
}
|
||||
@chmod($logFile, 0600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a full daily log aside before appending another entry.
|
||||
*
|
||||
* @param string $logFile Absolute path to today's active log.
|
||||
*/
|
||||
private function rotateFullLog(string $logFile): void
|
||||
{
|
||||
clearstatcache(true, $logFile);
|
||||
if (!is_file($logFile) || (int) filesize($logFile) < $this->maxFileBytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rotated = $this->logsDir . date('Y-m-d-His') . '-' . bin2hex(random_bytes(3)) . '-audit.txt';
|
||||
if (!rename($logFile, $rotated)) {
|
||||
throw new \RuntimeException('Unable to rotate audit log');
|
||||
}
|
||||
@chmod($rotated, 0600);
|
||||
}
|
||||
|
||||
/** Remove audit files older than the configured retention period. */
|
||||
private function pruneExpiredLogs(): void
|
||||
{
|
||||
$cutoff = time() - ($this->retentionDays * 86400);
|
||||
$logFiles = glob($this->logsDir . '*-audit.txt') ?: [];
|
||||
|
||||
foreach ($logFiles as $logFile) {
|
||||
if (is_file($logFile) && (int) filemtime($logFile) < $cutoff && !unlink($logFile)) {
|
||||
throw new \RuntimeException('Unable to remove expired audit log');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove control characters that could forge extra audit entries or columns.
|
||||
*
|
||||
* @param mixed $value Value destined for one tab-separated log field.
|
||||
*/
|
||||
private function normaliseLogField($value): string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
return str_replace(["\r", "\n", "\t"], [' ', ' ', ' '], $value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/**
|
||||
* Provides filesystem-backed throttling for failed login attempts.
|
||||
*
|
||||
* Limits are applied to both the source address and the source-address/username pair. This slows
|
||||
* password guessing without allowing one remote address to lock an account for everybody else.
|
||||
*/
|
||||
final class LoginRateLimiter
|
||||
{
|
||||
private string $path;
|
||||
private int $windowSeconds;
|
||||
private int $lockoutSeconds;
|
||||
private int $maxIdentityAttempts;
|
||||
private int $maxAddressAttempts;
|
||||
|
||||
/** Hard ceiling on tracked buckets, so a distributed flood of distinct addresses or login names cannot grow the state file without bound. */
|
||||
private const MAX_TRACKED_KEYS = 20000;
|
||||
|
||||
/** Configure the protected state file and throttling thresholds. */
|
||||
public function __construct(string $dataDirectory, array $options = [])
|
||||
{
|
||||
$directory = rtrim($dataDirectory, '/\\') . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
|
||||
throw new \RuntimeException('Unable to create login security directory');
|
||||
}
|
||||
@chmod($directory, 0700);
|
||||
|
||||
$this->path = $directory . 'login-attempts.json';
|
||||
$this->windowSeconds = max(60, (int) ($options['loginWindowSeconds'] ?? 900));
|
||||
$this->lockoutSeconds = max(60, (int) ($options['loginLockoutSeconds'] ?? 900));
|
||||
$this->maxIdentityAttempts = max(1, (int) ($options['loginMaxAttempts'] ?? 5));
|
||||
$this->maxAddressAttempts = max($this->maxIdentityAttempts, (int) ($options['loginMaxAddressAttempts'] ?? 20));
|
||||
}
|
||||
|
||||
/** Return the remaining lockout in seconds, or zero when another attempt is permitted. */
|
||||
public function retryAfter(string $address, string $username): int
|
||||
{
|
||||
return $this->withState(function (array &$state) use ($address, $username): int {
|
||||
$now = time();
|
||||
$this->prune($state, $now);
|
||||
$retryAt = max(
|
||||
(int) ($state[$this->addressKey($address)]['locked_until'] ?? 0),
|
||||
(int) ($state[$this->identityKey($address, $username)]['locked_until'] ?? 0)
|
||||
);
|
||||
return max(0, $retryAt - $now);
|
||||
});
|
||||
}
|
||||
|
||||
/** Record one denied login and lock a bucket once its configured threshold is reached. */
|
||||
public function recordFailure(string $address, string $username): void
|
||||
{
|
||||
$this->withState(function (array &$state) use ($address, $username): void {
|
||||
$now = time();
|
||||
$this->prune($state, $now);
|
||||
$this->increment($state, $this->addressKey($address), $this->maxAddressAttempts, $now);
|
||||
$this->increment($state, $this->identityKey($address, $username), $this->maxIdentityAttempts, $now);
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear successful login buckets so typographical mistakes do not linger unnecessarily. */
|
||||
public function clear(string $address, string $username): void
|
||||
{
|
||||
$this->withState(function (array &$state) use ($address, $username): void {
|
||||
unset($state[$this->addressKey($address)], $state[$this->identityKey($address, $username)]);
|
||||
$this->prune($state, time());
|
||||
});
|
||||
}
|
||||
|
||||
/** Update one attempt bucket and begin a fixed lockout when its threshold is reached. */
|
||||
private function increment(array &$state, string $key, int $limit, int $now): void
|
||||
{
|
||||
$record = $state[$key] ?? ['attempts' => [], 'locked_until' => 0];
|
||||
$record['attempts'][] = $now;
|
||||
if (count($record['attempts']) >= $limit) {
|
||||
$record['locked_until'] = $now + $this->lockoutSeconds;
|
||||
$record['attempts'] = [];
|
||||
}
|
||||
$state[$key] = $record;
|
||||
}
|
||||
|
||||
/** Remove expired attempts and stale unlocked buckets to keep the state file bounded. */
|
||||
private function prune(array &$state, int $now): void
|
||||
{
|
||||
$cutoff = $now - $this->windowSeconds;
|
||||
foreach ($state as $key => &$record) {
|
||||
$record['attempts'] = array_values(array_filter(
|
||||
is_array($record['attempts'] ?? null) ? $record['attempts'] : [],
|
||||
static fn($timestamp): bool => is_int($timestamp) && $timestamp >= $cutoff
|
||||
));
|
||||
$record['locked_until'] = (int) ($record['locked_until'] ?? 0);
|
||||
if ($record['attempts'] === [] && $record['locked_until'] <= $now) {
|
||||
unset($state[$key]);
|
||||
}
|
||||
}
|
||||
unset($record);
|
||||
|
||||
if (count($state) > self::MAX_TRACKED_KEYS) {
|
||||
// Evict the least recently active buckets first; an evicted bucket simply restarts its count.
|
||||
uasort($state, static function (array $a, array $b): int {
|
||||
$lastA = max((int) ($a['locked_until'] ?? 0), $a['attempts'] ? (int) end($a['attempts']) : 0);
|
||||
$lastB = max((int) ($b['locked_until'] ?? 0), $b['attempts'] ? (int) end($b['attempts']) : 0);
|
||||
return $lastA <=> $lastB;
|
||||
});
|
||||
$state = array_slice($state, -self::MAX_TRACKED_KEYS, null, true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialise state updates beneath an exclusive lock to avoid concurrent lost updates. */
|
||||
private function withState(callable $callback)
|
||||
{
|
||||
$handle = fopen($this->path, 'c+');
|
||||
if ($handle === false || !flock($handle, LOCK_EX)) {
|
||||
if (is_resource($handle)) {
|
||||
fclose($handle);
|
||||
}
|
||||
throw new \RuntimeException('Unable to access login security state');
|
||||
}
|
||||
@chmod($this->path, 0600);
|
||||
|
||||
try {
|
||||
rewind($handle);
|
||||
$decoded = json_decode((string) stream_get_contents($handle), true);
|
||||
$state = is_array($decoded) ? $decoded : [];
|
||||
$result = $callback($state);
|
||||
$json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new \RuntimeException('Unable to encode login security state');
|
||||
}
|
||||
rewind($handle);
|
||||
if (!ftruncate($handle, 0) || fwrite($handle, $json . "\n") === false || !fflush($handle)) {
|
||||
throw new \RuntimeException('Unable to write login security state');
|
||||
}
|
||||
@chmod($this->path, 0600);
|
||||
return $result;
|
||||
} finally {
|
||||
flock($handle, LOCK_UN);
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hash address data so the throttling file does not become a convenient IP-address register. */
|
||||
private function addressKey(string $address): string
|
||||
{
|
||||
return 'address:' . hash('sha256', $address);
|
||||
}
|
||||
|
||||
/** Hash the combined address and case-normalised username for the tighter identity bucket. */
|
||||
private function identityKey(string $address, string $username): string
|
||||
{
|
||||
return 'identity:' . hash('sha256', $address . "\0" . strtolower($username));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/**
|
||||
* The one list of uploadable media: allowed extensions, their categories, and the safety checks applied to every upload.
|
||||
*
|
||||
* Only extensions listed here are accepted, so executables, scripts, archives, HTML/SVG and macro-enabled Office files are refused
|
||||
* by omission. On top of that, content is sniffed and scanned for embedded code. This is best-effort hardening, not a malware scanner.
|
||||
*/
|
||||
final class MediaTypes
|
||||
{
|
||||
public const CATEGORIES = ['imagery' => 'Imagery', 'documents' => 'Documents', 'video' => 'Video', 'audio' => 'Audio'];
|
||||
|
||||
/** extension => category */
|
||||
private const EXTENSIONS = [
|
||||
'jpg' => 'imagery', 'jpeg' => 'imagery', 'png' => 'imagery', 'gif' => 'imagery', 'webp' => 'imagery',
|
||||
'pdf' => 'documents', 'docx' => 'documents', 'xlsx' => 'documents', 'pptx' => 'documents',
|
||||
'odt' => 'documents', 'ods' => 'documents', 'odp' => 'documents', 'txt' => 'documents', 'csv' => 'documents',
|
||||
'mp4' => 'video', 'm4v' => 'video', 'webm' => 'video', 'mov' => 'video', 'ogv' => 'video',
|
||||
'mp3' => 'audio', 'wav' => 'audio', 'ogg' => 'audio', 'oga' => 'audio', 'm4a' => 'audio', 'aac' => 'audio', 'flac' => 'audio',
|
||||
];
|
||||
|
||||
/** Extensions that must never appear anywhere in a filename (for example report.exe.pdf), even though the file is renamed. */
|
||||
private const DENIED_PARTS = [
|
||||
'exe', 'dll', 'bat', 'cmd', 'com', 'msi', 'scr', 'ps1', 'vbs', 'vbe', 'js', 'jse', 'jar', 'wsf', 'hta', 'cpl', 'reg',
|
||||
'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar', 'cgi', 'pl', 'py', 'rb', 'sh', 'bash', 'asp', 'aspx', 'jsp', 'jspx',
|
||||
'html', 'htm', 'xhtml', 'shtml', 'svg', 'xml', 'swf', 'lnk', 'apk', 'app', 'dmg', 'iso', 'so', 'bin',
|
||||
'docm', 'xlsm', 'pptm', 'dotm', 'xltm', 'potm', 'xlam', 'ppam',
|
||||
];
|
||||
|
||||
/** MIME types that always mean executable or markup content, whatever the extension claims. */
|
||||
private const BLOCKED_MIMES = [
|
||||
'application/x-dosexec', 'application/x-executable', 'application/x-sharedlib', 'application/x-mach-binary', 'application/x-msdownload',
|
||||
'application/x-elf', 'application/x-msi', 'application/java-archive', 'application/x-httpd-php', 'application/javascript',
|
||||
'text/javascript', 'text/x-php', 'text/x-shellscript', 'text/x-script.python', 'text/html', 'image/svg+xml', 'text/xml', 'application/xml',
|
||||
];
|
||||
|
||||
/** Case-insensitive markers of server-side or browser code, refused anywhere in any upload. Long enough never to occur by chance in compressed data. */
|
||||
private const CODE_MARKERS = ['<?php', '<script'];
|
||||
|
||||
/** Three-byte markers also refused, but only in plain-text uploads: in compressed media they appear by chance in any file of a few megabytes. */
|
||||
private const TEXT_CODE_MARKERS = ['<?=', '<%@', '<%='];
|
||||
|
||||
/** Entry names refused inside Office/OpenDocument packages. */
|
||||
private const DENIED_ENTRY_ENDINGS = ['.exe', '.dll', '.bat', '.cmd', '.com', '.msi', '.scr', '.ps1', '.vbs', '.js', '.jar', '.php', '.html', '.htm', '.sh', '.py', '.pl', '.bin'];
|
||||
|
||||
/** Expected ODF mimetype entry per extension. */
|
||||
private const ODF = ['odt' => 'application/vnd.oasis.opendocument.text', 'ods' => 'application/vnd.oasis.opendocument.spreadsheet', 'odp' => 'application/vnd.oasis.opendocument.presentation'];
|
||||
|
||||
/** Expected marker entry per OOXML extension. */
|
||||
private const OOXML = ['docx' => 'word/document.xml', 'xlsx' => 'xl/workbook.xml', 'pptx' => 'ppt/presentation.xml'];
|
||||
|
||||
public static function extensions(): array
|
||||
{
|
||||
return array_keys(self::EXTENSIONS);
|
||||
}
|
||||
|
||||
public static function categoryForExtension(string $extension): ?string
|
||||
{
|
||||
return self::EXTENSIONS[strtolower($extension)] ?? null;
|
||||
}
|
||||
|
||||
/** Category of a stored filename, from its extension. */
|
||||
public static function categoryForName(string $name): ?string
|
||||
{
|
||||
return self::categoryForExtension(pathinfo($name, PATHINFO_EXTENSION));
|
||||
}
|
||||
|
||||
/** Regular-expression fragment (unanchored) matching every stored media filename: legacy 32-hex names or slug-8hex names. */
|
||||
public static function nameRegex(): string
|
||||
{
|
||||
return '(?:[a-f0-9]{32}|[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?-[a-f0-9]{8})\.(?:' . implode('|', self::extensions()) . ')';
|
||||
}
|
||||
|
||||
public static function isManagedName(string $name): bool
|
||||
{
|
||||
return preg_match('/^' . self::nameRegex() . '$/', $name) === 1;
|
||||
}
|
||||
|
||||
/** New stored filename: a slug of the original name, a random suffix, and exactly one dot. */
|
||||
public static function storedName(string $originalName, string $extension): string
|
||||
{
|
||||
$base = pathinfo(basename($originalName), PATHINFO_FILENAME);
|
||||
if (function_exists('iconv')) {
|
||||
$base = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $base) ?: $base;
|
||||
}
|
||||
$slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($base)), '-');
|
||||
$slug = trim(substr($slug, 0, 60), '-');
|
||||
return ($slug !== '' ? $slug : 'file') . '-' . bin2hex(random_bytes(4)) . '.' . $extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check an uploaded file and report how to store it.
|
||||
*
|
||||
* @param array $limits maxBytes, maxWidth, maxHeight, maxPixels
|
||||
* @return array{ext: string, category: string, mime: string}
|
||||
* @throws \RuntimeException with a message safe to show the user
|
||||
*/
|
||||
public static function inspect(string $path, string $originalName, array $limits = []): array
|
||||
{
|
||||
$parts = explode('.', strtolower(basename(str_replace('\\', '/', $originalName))));
|
||||
$extension = count($parts) > 1 ? (string) end($parts) : '';
|
||||
$category = self::EXTENSIONS[$extension] ?? null;
|
||||
foreach (array_slice($parts, 1) as $part) {
|
||||
if (in_array($part, self::DENIED_PARTS, true)) {
|
||||
throw new \RuntimeException('Files of this type (.' . $part . ') are not allowed.');
|
||||
}
|
||||
}
|
||||
if ($category === null) {
|
||||
throw new \RuntimeException('This file type is not allowed. Accepted: images, documents (PDF, Word, Excel, PowerPoint, OpenDocument, text, CSV), video, and audio.');
|
||||
}
|
||||
$size = @filesize($path);
|
||||
if ($size === false || $size < 1) {
|
||||
throw new \RuntimeException('The file is empty.');
|
||||
}
|
||||
if (isset($limits['maxBytes']) && $size > $limits['maxBytes']) {
|
||||
throw new \RuntimeException('This file is larger than the ' . round($limits['maxBytes'] / 1048576, 1) . ' MB limit.');
|
||||
}
|
||||
|
||||
$head = (string) file_get_contents($path, false, null, 0, 8);
|
||||
foreach (["MZ", "\x7fELF", "#!", "\xCF\xFA\xED\xFE", "\xFE\xED\xFA\xCF", "\xCA\xFE\xBA\xBE"] as $signature) {
|
||||
if (str_starts_with($head, $signature)) {
|
||||
throw new \RuntimeException('Executable files are not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = (string) finfo_file($finfo, $path);
|
||||
finfo_close($finfo);
|
||||
if (in_array($mime, self::BLOCKED_MIMES, true)) {
|
||||
throw new \RuntimeException('Executable or script content is not allowed.');
|
||||
}
|
||||
self::assertNoCode($path, $extension === 'txt' || $extension === 'csv');
|
||||
|
||||
match (true) {
|
||||
$category === 'imagery' => self::checkImage($path, $extension, $mime, $limits),
|
||||
$extension === 'pdf' => self::checkPdf($path, $mime),
|
||||
isset(self::OOXML[$extension]) || isset(self::ODF[$extension]) => self::checkPackage($path, $extension),
|
||||
$extension === 'txt' || $extension === 'csv' => self::checkText($path, $mime),
|
||||
default => self::checkAv($path, $extension, $mime, $category),
|
||||
};
|
||||
return ['ext' => $extension === 'jpeg' ? 'jpg' : $extension, 'category' => $category, 'mime' => $mime];
|
||||
}
|
||||
|
||||
private static function assertNoCode(string $path, bool $plainText): void
|
||||
{
|
||||
$handle = fopen($path, 'rb');
|
||||
if ($handle === false) {
|
||||
throw new \RuntimeException('The file could not be read.');
|
||||
}
|
||||
$carry = '';
|
||||
while (!feof($handle)) {
|
||||
$chunk = (string) fread($handle, 1048576);
|
||||
$window = strtolower($carry . $chunk);
|
||||
foreach ($plainText ? array_merge(self::CODE_MARKERS, self::TEXT_CODE_MARKERS) : self::CODE_MARKERS as $marker) {
|
||||
if (str_contains($window, $marker)) {
|
||||
fclose($handle);
|
||||
throw new \RuntimeException('Files containing code are not allowed.');
|
||||
}
|
||||
}
|
||||
$carry = substr($chunk, -8);
|
||||
}
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
private static function checkImage(string $path, string $extension, string $mime, array $limits): void
|
||||
{
|
||||
$expected = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp'][$extension];
|
||||
if ($mime !== $expected) {
|
||||
throw new \RuntimeException('The file contents do not match its image type.');
|
||||
}
|
||||
$info = @getimagesize($path);
|
||||
if ($info === false) {
|
||||
throw new \RuntimeException('Uploaded file is not a valid image.');
|
||||
}
|
||||
[$width, $height] = $info;
|
||||
if ($width > ($limits['maxWidth'] ?? 8192) || $height > ($limits['maxHeight'] ?? 8192) || $width * $height > ($limits['maxPixels'] ?? 24 * 1024 * 1024)) {
|
||||
throw new \RuntimeException('Image dimensions exceed the configured limit.');
|
||||
}
|
||||
}
|
||||
|
||||
private static function checkPdf(string $path, string $mime): void
|
||||
{
|
||||
$data = (string) file_get_contents($path);
|
||||
if ($mime !== 'application/pdf' || !str_contains(substr($data, 0, 1024), '%PDF-')) {
|
||||
throw new \RuntimeException('The file contents do not match a PDF.');
|
||||
}
|
||||
if (preg_match('#/JavaScript|/JS\b|/Launch|/EmbeddedFile|/RichMedia#', $data)) {
|
||||
throw new \RuntimeException('PDFs containing scripts, launch actions or embedded files are not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
private static function checkText(string $path, string $mime): void
|
||||
{
|
||||
$data = (string) file_get_contents($path);
|
||||
if (str_contains($data, "\0") || !preg_match('//u', $data)) {
|
||||
throw new \RuntimeException('Text files must be plain UTF-8 text.');
|
||||
}
|
||||
if (!str_starts_with($mime, 'text/') && $mime !== 'application/csv') {
|
||||
throw new \RuntimeException('The file contents do not match a text file.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Audio and video must sniff as such; when libmagic only says "octet-stream", the container signature must match the extension. */
|
||||
private static function checkAv(string $path, string $extension, string $mime, string $category): void
|
||||
{
|
||||
if (str_starts_with($mime, 'audio/') || str_starts_with($mime, 'video/') || $mime === 'application/ogg') {
|
||||
return;
|
||||
}
|
||||
$head = (string) file_get_contents($path, false, null, 0, 12);
|
||||
$sync = strlen($head) > 1 && ord($head[0]) === 0xFF;
|
||||
$matches = match ($extension) {
|
||||
'mp4', 'm4v', 'mov', 'm4a' => in_array(substr($head, 4, 4), ['ftyp', 'moov', 'mdat', 'free', 'wide', 'skip', 'pnot'], true),
|
||||
'webm' => str_starts_with($head, "\x1A\x45\xDF\xA3"),
|
||||
'ogv', 'ogg', 'oga' => str_starts_with($head, 'OggS'),
|
||||
'mp3' => str_starts_with($head, 'ID3') || ($sync && (ord($head[1]) & 0xE0) === 0xE0),
|
||||
'aac' => $sync && (ord($head[1]) & 0xF0) === 0xF0,
|
||||
'wav' => str_starts_with($head, 'RIFF'),
|
||||
'flac' => str_starts_with($head, 'fLaC'),
|
||||
default => false,
|
||||
};
|
||||
if ($mime !== 'application/octet-stream' || !$matches) {
|
||||
throw new \RuntimeException('The file contents do not match ' . ($category === 'video' ? 'a video' : 'an audio') . ' file.');
|
||||
}
|
||||
}
|
||||
|
||||
/** OOXML and OpenDocument files are ZIP packages: read the directory only, and refuse macros, embedded objects, and executables. */
|
||||
private static function checkPackage(string $path, string $extension): void
|
||||
{
|
||||
$entries = self::zipEntries($path);
|
||||
if (isset(self::ODF[$extension])) {
|
||||
$type = self::zipRead($path, $entries['mimetype'] ?? null);
|
||||
if ($type !== self::ODF[$extension]) {
|
||||
throw new \RuntimeException('The file contents do not match an OpenDocument file.');
|
||||
}
|
||||
} elseif (!isset($entries['[Content_Types].xml']) || !isset($entries[self::OOXML[$extension]])) {
|
||||
throw new \RuntimeException('The file contents do not match an Office document.');
|
||||
}
|
||||
foreach (array_keys($entries) as $name) {
|
||||
$lower = strtolower((string) $name);
|
||||
$bad = str_contains($lower, 'vbaproject') || str_contains($lower, 'activex') || str_contains($lower, 'embeddings/')
|
||||
|| str_starts_with($lower, 'basic/') || str_starts_with($lower, 'scripts/') || str_contains($lower, '..') || str_starts_with($lower, '/');
|
||||
foreach (self::DENIED_ENTRY_ENDINGS as $ending) {
|
||||
$bad = $bad || str_ends_with($lower, $ending);
|
||||
}
|
||||
if ($bad) {
|
||||
throw new \RuntimeException('Documents containing macros, embedded objects or scripts are not allowed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, array{offset: int, csize: int, usize: int, method: int}> */
|
||||
private static function zipEntries(string $path): array
|
||||
{
|
||||
$size = (int) filesize($path);
|
||||
$tail = (string) file_get_contents($path, false, null, max(0, $size - 65557));
|
||||
$eocd = strrpos($tail, "PK\x05\x06");
|
||||
if ($eocd === false || strlen($tail) < $eocd + 22 || !str_starts_with((string) file_get_contents($path, false, null, 0, 4), "PK\x03\x04")) {
|
||||
throw new \RuntimeException('The file is not a valid document package.');
|
||||
}
|
||||
$count = unpack('v', substr($tail, $eocd + 10, 2))[1];
|
||||
$directorySize = unpack('V', substr($tail, $eocd + 12, 4))[1];
|
||||
$directoryOffset = unpack('V', substr($tail, $eocd + 16, 4))[1];
|
||||
if ($count === 0xFFFF || $directorySize === 0xFFFFFFFF || $count > 5000 || $directorySize > 5 * 1048576) {
|
||||
throw new \RuntimeException('The document package is too complex.');
|
||||
}
|
||||
$directory = (string) file_get_contents($path, false, null, $directoryOffset, $directorySize);
|
||||
$entries = [];
|
||||
$position = 0;
|
||||
while ($position + 46 <= strlen($directory) && substr($directory, $position, 4) === "PK\x01\x02") {
|
||||
$method = unpack('v', substr($directory, $position + 10, 2))[1];
|
||||
$csize = unpack('V', substr($directory, $position + 20, 4))[1];
|
||||
$usize = unpack('V', substr($directory, $position + 24, 4))[1];
|
||||
[$nameLength, $extraLength, $commentLength] = array_values(unpack('v3', substr($directory, $position + 28, 6)));
|
||||
$offset = unpack('V', substr($directory, $position + 42, 4))[1];
|
||||
$entries[substr($directory, $position + 46, $nameLength)] = ['offset' => $offset, 'csize' => $csize, 'usize' => $usize, 'method' => $method];
|
||||
$position += 46 + $nameLength + $extraLength + $commentLength;
|
||||
}
|
||||
if (!$entries) {
|
||||
throw new \RuntimeException('The file is not a valid document package.');
|
||||
}
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/** Read one stored (uncompressed) entry, used only for the small ODF mimetype file. */
|
||||
private static function zipRead(string $path, ?array $entry): string
|
||||
{
|
||||
if ($entry === null || $entry['method'] !== 0 || $entry['usize'] > 256) {
|
||||
return '';
|
||||
}
|
||||
$header = (string) file_get_contents($path, false, null, $entry['offset'], 30);
|
||||
if (strlen($header) < 30) {
|
||||
return '';
|
||||
}
|
||||
[$nameLength, $extraLength] = array_values(unpack('v2', substr($header, 26, 4)));
|
||||
return (string) file_get_contents($path, false, null, $entry['offset'] + 30 + $nameLength + $extraLength, $entry['usize']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/** Raised when a URI does not resolve to an existing managed page. */
|
||||
final class PageNotFoundException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/** Resolves public page URIs to filesystem paths inside the document root, rejecting anything that escapes it. */
|
||||
final class PagePaths
|
||||
{
|
||||
public function __construct(private string $documentRoot, private int $maxManagedPages, private int $maxScannedEntries)
|
||||
{
|
||||
}
|
||||
|
||||
/** Resolve an existing public URI to a canonical, in-root HTML file path. */
|
||||
public function existing(string $uri): string
|
||||
{
|
||||
$uri = $this->normaliseUri($uri);
|
||||
$candidate = $this->documentRoot . ltrim($uri, '/');
|
||||
if (is_dir($candidate)) {
|
||||
$candidate = rtrim($candidate, '/\\') . '/index.html';
|
||||
} elseif (!preg_match('/\.html?$/i', $candidate)) {
|
||||
$candidate .= '.html';
|
||||
}
|
||||
$real = realpath($candidate);
|
||||
if (!$real || !str_starts_with($real, $this->documentRoot) || str_starts_with($real, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR) || !preg_match('/\.html?$/i', $real)) {
|
||||
throw new PageNotFoundException('Invalid page path');
|
||||
}
|
||||
return $real;
|
||||
}
|
||||
|
||||
/** Build a safe path for a page that may not exist yet. */
|
||||
public function newPath(string $uri): string
|
||||
{
|
||||
$path = $this->documentRoot . ltrim($uri, '/');
|
||||
if (!str_starts_with($path, $this->documentRoot)) {
|
||||
throw new \RuntimeException('Invalid page path');
|
||||
}
|
||||
$this->assertNoSymlinks($path);
|
||||
$parent = realpath(dirname($path));
|
||||
if ($parent && !str_starts_with($parent . DIRECTORY_SEPARATOR, $this->documentRoot)) {
|
||||
throw new \RuntimeException('Invalid page path');
|
||||
}
|
||||
if (str_starts_with($path, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR)) {
|
||||
throw new \RuntimeException('CMS files cannot be managed as pages');
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
/** Reject page targets whose existing path components contain symbolic links. */
|
||||
public function assertNoSymlinks(string $path): void
|
||||
{
|
||||
$relative = substr($path, strlen($this->documentRoot));
|
||||
$current = rtrim($this->documentRoot, DIRECTORY_SEPARATOR);
|
||||
foreach (explode('/', str_replace('\\', '/', $relative)) as $component) {
|
||||
if ($component === '') {
|
||||
continue;
|
||||
}
|
||||
$current .= DIRECTORY_SEPARATOR . $component;
|
||||
if (is_link($current)) {
|
||||
throw new \RuntimeException('Symbolic links are not permitted in managed page paths');
|
||||
}
|
||||
if (file_exists($current)) {
|
||||
$real = realpath($current);
|
||||
if ($real === false || !str_starts_with($real . (is_dir($real) ? DIRECTORY_SEPARATOR : ''), $this->documentRoot)) {
|
||||
throw new \RuntimeException('Invalid page path');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Canonicalise a new page URI, append .html when absent, and enforce safe characters. */
|
||||
public function normaliseNewUri(string $uri): string
|
||||
{
|
||||
$uri = $this->normaliseUri($uri);
|
||||
if (!preg_match('/\.html?$/i', $uri)) {
|
||||
$uri .= '.html';
|
||||
}
|
||||
if (!preg_match('#^/[a-zA-Z0-9_./-]+\.html?$#', $uri)) {
|
||||
throw new \RuntimeException('Invalid page name');
|
||||
}
|
||||
return $uri;
|
||||
}
|
||||
|
||||
/** Canonicalise a request URI and reject traversal or null-byte input. */
|
||||
public function normaliseUri(string $uri): string
|
||||
{
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
|
||||
$path = '/' . ltrim(str_replace('\\', '/', $path), '/');
|
||||
if (str_contains($path, '..') || str_contains($path, "\0")) {
|
||||
throw new \RuntimeException('Invalid URI');
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
/** Accept an absolute HTTP(S) link or normalise a local site path. */
|
||||
public function normaliseLink(string $url): string
|
||||
{
|
||||
if (preg_match('#^https?://#i', $url)) {
|
||||
return filter_var($url, FILTER_SANITIZE_URL);
|
||||
}
|
||||
return $this->normaliseUri($url);
|
||||
}
|
||||
|
||||
/** Return every public .html or .htm file while excluding the CMS application itself. */
|
||||
public function files(): array
|
||||
{
|
||||
$files = [];
|
||||
$scanned = 0;
|
||||
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->documentRoot, \FilesystemIterator::SKIP_DOTS));
|
||||
foreach ($iterator as $file) {
|
||||
$scanned++;
|
||||
if ($scanned > $this->maxScannedEntries) {
|
||||
throw new \RuntimeException('Document root scan limit exceeded');
|
||||
}
|
||||
if (count($files) >= $this->maxManagedPages) {
|
||||
throw new \RuntimeException('Managed page limit exceeded');
|
||||
}
|
||||
if ($file->isLink()) {
|
||||
continue;
|
||||
}
|
||||
$path = $file->getRealPath();
|
||||
if (!$file->isFile() || $path === false || !str_starts_with($path, $this->documentRoot) || !preg_match('/\.html?$/i', $path)) {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($path, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR)) {
|
||||
continue;
|
||||
}
|
||||
$files[] = $path;
|
||||
}
|
||||
return $files;
|
||||
}
|
||||
|
||||
/** Convert an absolute page path back into its public, slash-separated URI. */
|
||||
public function uriFor(string $path): string
|
||||
{
|
||||
return '/' . str_replace(DIRECTORY_SEPARATOR, '/', substr($path, strlen($this->documentRoot)));
|
||||
}
|
||||
/**
|
||||
* Whether a page's local asset reference points at a real file inside the site.
|
||||
*
|
||||
* @return bool|null null for external or non-file references (http:, //, data:, #anchor), which are not checked.
|
||||
*/
|
||||
public function refExists(string $pagePath, string $ref, string $basePath = ''): ?bool
|
||||
{
|
||||
$ref = trim($ref);
|
||||
if ($ref === '' || preg_match('#^([a-z][a-z0-9+.-]*:|//|\#)#i', $ref)) {
|
||||
return null;
|
||||
}
|
||||
$ref = rawurldecode((string) preg_replace('/[?#].*$/s', '', $ref));
|
||||
if ($ref === '' || str_contains($ref, "\0")) {
|
||||
return $ref === '' ? null : false;
|
||||
}
|
||||
if ($ref[0] === '/') {
|
||||
// The upload endpoint writes URLs with the site's URL prefix; strip it to get a path inside the site.
|
||||
if ($basePath !== '' && str_starts_with($ref, $basePath . '/')) {
|
||||
$ref = substr($ref, strlen($basePath));
|
||||
}
|
||||
$candidate = $this->documentRoot . ltrim($ref, '/');
|
||||
} else {
|
||||
$candidate = dirname($pagePath) . DIRECTORY_SEPARATOR . $ref;
|
||||
}
|
||||
$real = realpath($candidate);
|
||||
return $real !== false && str_starts_with($real, $this->documentRoot) && is_file($real);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/** Applies consistent browser security and cache-control headers to CMS responses. */
|
||||
final class SecurityHeaders
|
||||
{
|
||||
/** Protect an HTML administration page, optionally allowing the pinned UI CDNs. */
|
||||
public static function html(bool $allowUiCdns = false, ?bool $secureTransport = null): void
|
||||
{
|
||||
self::common($secureTransport);
|
||||
header('Cache-Control: no-store, private');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
$scriptSources = $allowUiCdns
|
||||
? "'self' https://code.jquery.com"
|
||||
: "'self'";
|
||||
$styleSources = $allowUiCdns
|
||||
? "'self' 'unsafe-inline' https://code.jquery.com"
|
||||
: "'self'";
|
||||
|
||||
header("Content-Security-Policy: default-src 'self'; script-src {$scriptSources}; style-src {$styleSources}; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'");
|
||||
}
|
||||
|
||||
/** Protect JSON API responses and prevent authenticated data from being cached. */
|
||||
public static function json(?bool $secureTransport = null): void
|
||||
{
|
||||
self::common($secureTransport);
|
||||
header('Cache-Control: no-store, private');
|
||||
header('Pragma: no-cache');
|
||||
header("Content-Security-Policy: default-src 'none'; frame-ancestors 'none'");
|
||||
header('Content-Type: application/json');
|
||||
}
|
||||
|
||||
/** Emit headers shared by both document and API responses. */
|
||||
private static function common(?bool $secureTransport): void
|
||||
{
|
||||
header_remove('X-Powered-By');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()');
|
||||
header('Cross-Origin-Opener-Policy: same-origin');
|
||||
header('Cross-Origin-Resource-Policy: same-origin');
|
||||
$isHttps = $secureTransport ?? (
|
||||
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (isset($_SERVER['SERVER_PORT']) && (int) $_SERVER['SERVER_PORT'] === 443)
|
||||
);
|
||||
if ($isHttps) {
|
||||
header('Strict-Transport-Security: max-age=31536000');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/**
|
||||
* Finds what NeoCMS could edit in a page (content containers, images, SEO tags) and adds the markers.
|
||||
*
|
||||
* Pages are never re-serialised through a DOM parser: a tokenizer locates tag offsets and apply() splices
|
||||
* text in, so every byte outside the inserted markers is preserved exactly.
|
||||
*/
|
||||
final class SiteAnalyser
|
||||
{
|
||||
private const VOID = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
|
||||
private const SKIPPED = ['header', 'nav', 'footer', 'aside', 'form'];
|
||||
private const SKIPPED_ROLES = ['navigation', 'banner', 'contentinfo'];
|
||||
private const MIN_SECTION_TEXT = 40;
|
||||
|
||||
/** Describe a page: existing and proposed editable regions, images, local references, and SEO tags present. */
|
||||
public static function analyse(string $html, string $editableClass, bool $allNavs = false): array
|
||||
{
|
||||
$c = self::scan($html, $editableClass, true);
|
||||
return [
|
||||
'title' => $c['title'],
|
||||
'existingRegions' => $c['existing'],
|
||||
'proposed' => array_map(fn(int $i) => $c['els'][$i]['name'], $c['regions']),
|
||||
'images' => array_map(fn(array $i) => ['src' => $i['src'], 'hasAlt' => $i['hasAlt'], 'inRegion' => $i['inRegion'], 'tagged' => $i['tagged']], $c['images']),
|
||||
'refs' => $c['refs'],
|
||||
'seo' => $c['seo'],
|
||||
'navs' => array_values(array_map(
|
||||
fn(array $n) => ['menu' => $n['menu'] ?? ($allNavs ? $n['name'] : $n['slot']), 'links' => count($n['links']), 'tagged' => $n['menu'] !== null, 'sig' => md5(implode("\n", array_column($n['links'], 'url')))],
|
||||
array_filter($c['navs'], fn(array $n) => $n['menu'] !== null || ($allNavs ? $n['name'] : $n['slot']) !== null)
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
/** Every navigation block in a page with its byte offsets, links and any data-neo-menu name (used to replace menus in place). */
|
||||
public static function navs(string $html): array
|
||||
{
|
||||
return self::navList($html, self::elements($html));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the requested markers into a page.
|
||||
*
|
||||
* @param array $options Booleans: content, images, seo, menus; allNavs also names navs that are not clearly header or footer menus.
|
||||
* @return array|null ['html' => string, 'changes' => string[], 'menus' => [name => links]], or null when the page needs no change.
|
||||
*/
|
||||
public static function apply(string $html, string $editableClass, array $options): ?array
|
||||
{
|
||||
$c = self::scan($html, $editableClass, !empty($options['content']));
|
||||
$edits = [];
|
||||
$changes = [];
|
||||
|
||||
if (!empty($options['content'])) {
|
||||
foreach ($c['regions'] as $i) {
|
||||
$edit = self::classEdit($html, $c['els'][$i], $editableClass);
|
||||
if ($edit) {
|
||||
$edits[] = $edit;
|
||||
$changes[] = "Made <{$c['els'][$i]['name']}> editable";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($options['images'])) {
|
||||
foreach ($c['images'] as $img) {
|
||||
if (!$img['inRegion'] && !$img['tagged'] && $img['taggable']) {
|
||||
$edits[] = [$img['insertAt'], ' data-neo-image'];
|
||||
$changes[] = !empty($img['placeholder'])
|
||||
? 'Made image placeholder editable' . ($img['label'] !== '' ? ': ' . $img['label'] : '')
|
||||
: 'Made image editable: ' . $img['src'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($options['seo']) && ($seo = self::seoEdit($html, $c))) {
|
||||
$edits[] = $seo['edit'];
|
||||
array_push($changes, ...$seo['changes']);
|
||||
}
|
||||
$menus = [];
|
||||
if (!empty($options['menus'])) {
|
||||
$named = array_filter(array_column($c['navs'], 'menu'));
|
||||
foreach ($c['navs'] as $nav) {
|
||||
$name = !empty($options['allNavs']) ? $nav['name'] : $nav['slot'];
|
||||
// One nav per name per page, and never over a menu the page already marks by hand.
|
||||
if ($nav['menu'] === null && $name !== null && !isset($menus[$name]) && !in_array($name, $named, true)) {
|
||||
$edits[] = [$nav['insertAt'], ' data-neo-menu="' . $name . '"'];
|
||||
$changes[] = 'Marked ' . $name . ' navigation as a menu (' . count($nav['links']) . ' links)';
|
||||
$menus[$name] = $nav['links'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$edits) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($edits, fn(array $a, array $b) => $b[0] <=> $a[0]);
|
||||
foreach ($edits as [$offset, $text]) {
|
||||
$html = substr($html, 0, $offset) . $text . substr($html, $offset);
|
||||
}
|
||||
return ['html' => $html, 'changes' => $changes, 'menus' => $menus];
|
||||
}
|
||||
|
||||
/** Navigation blocks: <nav> or role=navigation, classified as the primary (header) or secondary (footer) menu when recognisable. */
|
||||
private static function navList(string $html, array $els): array
|
||||
{
|
||||
$navs = [];
|
||||
foreach ($els as $k => $e) {
|
||||
if ($e['name'] !== 'nav' && strtolower((string) self::attr($e['attrs'], 'role')) !== 'navigation') {
|
||||
continue;
|
||||
}
|
||||
$links = [];
|
||||
foreach ($els as $a) {
|
||||
if ($a['name'] !== 'a' || ($href = self::attr($a['attrs'], 'href')) === null) {
|
||||
continue;
|
||||
}
|
||||
$inside = false;
|
||||
$lis = [];
|
||||
for ($p = $a['parent']; $p >= 0; $p = $els[$p]['parent']) {
|
||||
if ($p === $k) {
|
||||
$inside = true;
|
||||
break;
|
||||
}
|
||||
if ($els[$p]['name'] === 'li') {
|
||||
$lis[] = $p;
|
||||
}
|
||||
}
|
||||
$href = trim(html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
$label = self::text($html, $a) ?: trim((string) self::attr($a['attrs'], 'aria-label'));
|
||||
if (!$inside || $href === '' || $label === '' || stripos($href, 'javascript:') === 0) {
|
||||
continue;
|
||||
}
|
||||
// A link inside a nested list is a child of the link that owns the enclosing list item.
|
||||
$parent = '';
|
||||
if (isset($lis[1])) {
|
||||
foreach ($els as $b) {
|
||||
if ($b['name'] === 'a' && $b['parent'] === $lis[1]) {
|
||||
$parent = self::text($html, $b);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$links[] = ['label' => $label, 'url' => $href, 'parent' => $parent];
|
||||
}
|
||||
|
||||
$ancestors = [];
|
||||
for ($p = $e['parent']; $p >= 0; $p = $els[$p]['parent']) {
|
||||
$ancestors[] = $els[$p]['name'] . ':' . strtolower((string) self::attr($els[$p]['attrs'], 'role'));
|
||||
}
|
||||
$in = fn(string $tag, string $role) => in_array($tag . ':', $ancestors, true) || in_array(':' . $role, $ancestors, true) || in_array($tag . ':' . $role, $ancestors, true);
|
||||
$label = strtolower(self::attr($e['attrs'], 'aria-label') . ' ' . self::attr($e['attrs'], 'class') . ' ' . self::attr($e['attrs'], 'id'));
|
||||
$slot = null;
|
||||
if ($links) {
|
||||
if ($in('footer', 'contentinfo') || preg_match('/\b(footer|secondary|legal)\b/', $label)) {
|
||||
$slot = 'secondary';
|
||||
} elseif ($in('header', 'banner') || preg_match('/\b(main|primary|site-nav|top)\b/', $label)) {
|
||||
$slot = 'primary';
|
||||
}
|
||||
}
|
||||
$navs[] = [
|
||||
'menu' => ($m = self::attr($e['attrs'], 'data-neo-menu')) !== null && $m !== '' ? $m : null,
|
||||
'slot' => $slot, 'name' => $slot ?? ($links ? (trim(preg_replace('/[^a-z0-9]+/', '-', strtolower(substr((string) (self::attr($e['attrs'], 'aria-label') ?: self::attr($e['attrs'], 'id')), 0, 40))), '-') ?: 'navigation') : null), 'links' => $links, 'attrs' => $e['attrs'],
|
||||
'end' => $e['end'], 'close' => $e['close'], 'insertAt' => $e['start'] + 1 + strlen($e['name']),
|
||||
];
|
||||
}
|
||||
return $navs;
|
||||
}
|
||||
|
||||
/** Tokenise a page and gather everything analyse() and apply() need. */
|
||||
private static function scan(string $html, string $class, bool $withRegions): array
|
||||
{
|
||||
$els = self::elements($html);
|
||||
$existing = 0;
|
||||
foreach ($els as $k => $e) {
|
||||
$els[$k]['editable'] = self::hasClassToken($e['attrs'], $class) || ($e['parent'] >= 0 && $els[$e['parent']]['editable']);
|
||||
$existing += self::hasClassToken($e['attrs'], $class) ? 1 : 0;
|
||||
}
|
||||
|
||||
$regions = ($withRegions && $existing === 0) ? self::chooseRegions($html, $els) : [];
|
||||
$inChosen = static function (int $k) use ($els, $regions): bool {
|
||||
for ($i = $k; $i >= 0; $i = $els[$i]['parent']) {
|
||||
if (in_array($i, $regions, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Containers that already hold a real <img> are not placeholders.
|
||||
$hasImg = [];
|
||||
foreach ($els as $e) {
|
||||
for ($p = $e['name'] === 'img' ? $e['parent'] : -1; $p >= 0; $p = $els[$p]['parent']) {
|
||||
$hasImg[$p] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$images = [];
|
||||
$refs = [];
|
||||
$meta = [];
|
||||
$title = null;
|
||||
$h1 = '';
|
||||
$firstParagraph = '';
|
||||
foreach ($els as $k => $e) {
|
||||
$name = $e['name'];
|
||||
if ($name === 'img') {
|
||||
$src = self::attr($e['attrs'], 'src');
|
||||
$images[] = [
|
||||
'src' => (string) $src,
|
||||
'hasAlt' => self::attr($e['attrs'], 'alt') !== null,
|
||||
'tagged' => self::attr($e['attrs'], 'data-neo-image') !== null,
|
||||
'inRegion' => $e['editable'] || $inChosen($k),
|
||||
'taggable' => $src !== null && $src !== '' && !str_starts_with(strtolower(trim($src)), 'data:')
|
||||
&& !(self::attr($e['attrs'], 'width') === '1' && self::attr($e['attrs'], 'height') === '1'),
|
||||
'insertAt' => $e['start'] + 1 + strlen($name),
|
||||
'skip' => $e['skip'],
|
||||
];
|
||||
if ($src) {
|
||||
$refs[] = ['type' => 'img', 'url' => $src];
|
||||
}
|
||||
} elseif (strtolower((string) self::attr($e['attrs'], 'role')) === 'img' && !in_array($name, ['svg'], true)
|
||||
&& self::attr($e['attrs'], 'aria-hidden') !== 'true' && empty($hasImg[$k])) {
|
||||
// A styled box standing in for a photo (for example a "Your photo here" block).
|
||||
$images[] = [
|
||||
'src' => '', 'hasAlt' => true, 'placeholder' => true,
|
||||
'label' => (string) self::attr($e['attrs'], 'aria-label'),
|
||||
'tagged' => self::attr($e['attrs'], 'data-neo-image') !== null,
|
||||
'inRegion' => $e['editable'] || $inChosen($k),
|
||||
'taggable' => true,
|
||||
'insertAt' => $e['start'] + 1 + strlen($name),
|
||||
'skip' => $e['skip'],
|
||||
];
|
||||
} elseif ($name === 'link' && preg_match('/\bstylesheet\b/i', (string) self::attr($e['attrs'], 'rel')) && ($h = self::attr($e['attrs'], 'href'))) {
|
||||
$refs[] = ['type' => 'link', 'url' => $h];
|
||||
} elseif ($name === 'script' && ($s = self::attr($e['attrs'], 'src'))) {
|
||||
$refs[] = ['type' => 'script', 'url' => $s];
|
||||
} elseif ($name === 'meta') {
|
||||
$key = self::attr($e['attrs'], 'name') ?? self::attr($e['attrs'], 'property');
|
||||
if ($key !== null && trim((string) self::attr($e['attrs'], 'content')) !== '') {
|
||||
$meta[strtolower($key)] = trim(html_entity_decode((string) self::attr($e['attrs'], 'content'), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
}
|
||||
} elseif ($name === 'title' && $title === null) {
|
||||
$title = self::text($html, $e);
|
||||
} elseif ($name === 'h1' && $h1 === '') {
|
||||
$h1 = self::text($html, $e);
|
||||
} elseif ($name === 'p' && $firstParagraph === '' && !$e['skip'] && strlen($t = self::text($html, $e)) >= 20) {
|
||||
$firstParagraph = $t;
|
||||
}
|
||||
}
|
||||
$canonical = false;
|
||||
foreach ($els as $e) {
|
||||
if ($e['name'] === 'link' && preg_match('/\bcanonical\b/i', (string) self::attr($e['attrs'], 'rel'))) {
|
||||
$canonical = true;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'navs' => self::navList($html, $els),
|
||||
'els' => $els, 'existing' => $existing, 'regions' => $regions, 'images' => $images, 'refs' => $refs,
|
||||
'title' => (string) $title, 'h1' => $h1, 'paragraph' => $firstParagraph, 'metaText' => $meta,
|
||||
'seo' => [
|
||||
'title' => trim((string) $title) !== '', 'description' => isset($meta['description']), 'canonical' => $canonical,
|
||||
'ogTitle' => isset($meta['og:title']), 'ogDescription' => isset($meta['og:description']),
|
||||
'ogImage' => isset($meta['og:image']), 'robots' => isset($meta['robots']),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** Flat list of elements with source offsets, parent index, skipped-subtree and close-position data. */
|
||||
private static function elements(string $html): array
|
||||
{
|
||||
$pattern = '#<!--.*?-->|<(script|style|textarea|template)\b[^>]*>.*?</\1\s*>|<(/?)([a-zA-Z][a-zA-Z0-9:-]*)((?:"[^"]*"|\'[^\']*\'|[^>"\'])*)>#is';
|
||||
preg_match_all($pattern, $html, $tokens, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
||||
|
||||
$els = [];
|
||||
$stack = [];
|
||||
foreach ($tokens as $t) {
|
||||
// Group 3 is the tag name; comments and raw script/style blocks never set it, but script src still matters.
|
||||
if (!isset($t[3]) || $t[3][1] < 0) {
|
||||
if (!empty($t[1][0]) && preg_match('#^<script\b([^>]*)>#i', $t[0][0], $m)) {
|
||||
$els[] = ['name' => 'script', 'attrs' => $m[1], 'start' => $t[0][1], 'end' => $t[0][1] + strlen($m[0]), 'parent' => $stack ? $stack[count($stack) - 1] : -1, 'close' => $t[0][1] + strlen($t[0][0]), 'skip' => true, 'editable' => false];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$name = strtolower($t[3][0]);
|
||||
if ($t[2][0] === '/') {
|
||||
// Close the nearest open element of this name and anything implicitly left open inside it.
|
||||
for ($j = count($stack) - 1; $j >= 0 && $els[$stack[$j]]['name'] !== $name; $j--);
|
||||
if ($j >= 0) {
|
||||
for ($x = count($stack) - 1; $x >= $j; $x--) {
|
||||
$els[$stack[$x]]['close'] = $t[0][1];
|
||||
}
|
||||
$stack = array_slice($stack, 0, $j);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$parent = $stack ? $stack[count($stack) - 1] : -1;
|
||||
$attrs = $t[4][0];
|
||||
$skip = in_array($name, self::SKIPPED, true)
|
||||
|| in_array(strtolower((string) self::attr($attrs, 'role')), self::SKIPPED_ROLES, true)
|
||||
|| ($parent >= 0 && $els[$parent]['skip']);
|
||||
$els[] = ['name' => $name, 'attrs' => $attrs, 'start' => $t[0][1], 'end' => $t[0][1] + strlen($t[0][0]), 'parent' => $parent, 'close' => null, 'skip' => $skip, 'editable' => false];
|
||||
if (!in_array($name, self::VOID, true) && !str_ends_with(rtrim($attrs), '/')) {
|
||||
$stack[] = count($els) - 1;
|
||||
}
|
||||
}
|
||||
return $els;
|
||||
}
|
||||
|
||||
/** Pick the containers to make editable: <main>, else top-level articles, else text-bearing sections. */
|
||||
private static function chooseRegions(string $html, array $els): array
|
||||
{
|
||||
$isMain = fn(array $e) => $e['name'] === 'main' || strtolower((string) self::attr($e['attrs'], 'role')) === 'main';
|
||||
foreach ([$isMain, fn(array $e) => $e['name'] === 'article', fn(array $e) => $e['name'] === 'section' && strlen(self::text($html, $e)) >= self::MIN_SECTION_TEXT] as $match) {
|
||||
$chosen = [];
|
||||
foreach ($els as $k => $e) {
|
||||
if ($e['skip'] || !$match($e)) {
|
||||
continue;
|
||||
}
|
||||
for ($p = $e['parent']; $p >= 0; $p = $els[$p]['parent']) {
|
||||
if (in_array($p, $chosen, true)) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
$chosen[] = $k;
|
||||
}
|
||||
if ($chosen) {
|
||||
return $chosen;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Edit that adds the editable class to an element's opening tag, or null when it cannot be done safely. */
|
||||
private static function classEdit(string $html, array $el, string $class): ?array
|
||||
{
|
||||
$tag = substr($html, $el['start'], $el['end'] - $el['start']);
|
||||
if (preg_match('/\sclass\s*=\s*(["\'])(.*?)\1/is', $tag, $m, PREG_OFFSET_CAPTURE)) {
|
||||
$closeQuote = $m[0][1] + strlen($m[0][0]) - 1;
|
||||
return [$el['start'] + $closeQuote, trim($m[2][0]) === '' ? $class : ' ' . $class];
|
||||
}
|
||||
if (preg_match('/\sclass\s*=/i', $tag)) {
|
||||
return null; // Unquoted class value: leave for manual tagging.
|
||||
}
|
||||
return [$el['start'] + 1 + strlen($el['name']), ' class="' . $class . '"'];
|
||||
}
|
||||
|
||||
/** One combined insertion of every missing SEO tag just before </head>, with values derived from the page. */
|
||||
private static function seoEdit(string $html, array $c): ?array
|
||||
{
|
||||
if (!preg_match('#^([ \t]*)</head\s*>#im', $html, $m, PREG_OFFSET_CAPTURE)
|
||||
&& !preg_match('#</head\s*>#i', $html, $m, PREG_OFFSET_CAPTURE)) {
|
||||
return null;
|
||||
}
|
||||
$seo = $c['seo'];
|
||||
$esc = static fn(string $s) => htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$trim = static fn(string $s, int $n) => mb_strlen($s) > $n ? rtrim(mb_substr($s, 0, $n - 3)) . '...' : $s;
|
||||
$valid = static fn(string $s) => $s !== '' && mb_check_encoding($s, 'UTF-8');
|
||||
|
||||
$title = trim($c['title']) !== '' ? trim($c['title']) : $c['h1'];
|
||||
// Prefer the page's own description over one derived from its first paragraph.
|
||||
$description = (string) ($c['metaText']['description'] ?? '') !== '' ? $c['metaText']['description'] : ($valid($c['paragraph']) ? $trim($c['paragraph'], 155) : '');
|
||||
$image = '';
|
||||
// A content image makes a better social preview than a header logo.
|
||||
foreach ([false, true] as $allowSkipped) {
|
||||
foreach ($c['images'] as $img) {
|
||||
// Open Graph needs an absolute or root-relative address; a page-relative one would be wrong.
|
||||
if ($img['taggable'] && preg_match('#^(/|https?://)#i', $img['src']) && ($allowSkipped || !$img['skip'])) {
|
||||
$image = $img['src'];
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$tags = [];
|
||||
$changes = [];
|
||||
if (!$seo['title'] && $valid($c['h1'])) {
|
||||
$tags[] = '<title>' . $esc($c['h1']) . '</title>';
|
||||
$changes[] = 'Added page title';
|
||||
}
|
||||
if (!$seo['description'] && $description !== '') {
|
||||
$tags[] = '<meta name="description" content="' . $esc($description) . '">';
|
||||
$changes[] = 'Added meta description';
|
||||
}
|
||||
if (!$seo['ogTitle'] && $valid($title)) {
|
||||
$tags[] = '<meta property="og:title" content="' . $esc($title) . '">';
|
||||
$changes[] = 'Added og:title';
|
||||
}
|
||||
if (!$seo['ogDescription'] && $description !== '') {
|
||||
$tags[] = '<meta property="og:description" content="' . $esc($description) . '">';
|
||||
$changes[] = 'Added og:description';
|
||||
}
|
||||
if (!$seo['ogImage'] && $image !== '') {
|
||||
$tags[] = '<meta property="og:image" content="' . $esc($image) . '">';
|
||||
$changes[] = 'Added og:image';
|
||||
}
|
||||
if (!$tags) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$atLineStart = isset($m[1]) && $m[1][0] !== null && $m[0][1] === $m[1][1];
|
||||
$offset = $m[0][1];
|
||||
// Match the page's own line endings so CRLF files do not end up with mixed endings.
|
||||
$nl = str_contains($html, "\r\n") ? "\r\n" : "\n";
|
||||
$body = ' ' . implode($nl . ' ', $tags) . $nl;
|
||||
$text = $atLineStart ? $body : $nl . $body;
|
||||
|
||||
return ['edit' => [$offset, $text], 'changes' => $changes];
|
||||
}
|
||||
|
||||
/** Visible text of an element (tags stripped, entities decoded, whitespace collapsed). */
|
||||
private static function text(string $html, array $el): string
|
||||
{
|
||||
$end = $el['close'] ?? $el['end'];
|
||||
$inner = substr($html, $el['end'], max(0, $end - $el['end']));
|
||||
return trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($inner), ENT_QUOTES | ENT_HTML5, 'UTF-8')) ?? '');
|
||||
}
|
||||
|
||||
private static function hasClassToken(string $attrs, string $class): bool
|
||||
{
|
||||
return in_array($class, preg_split('/\s+/', (string) self::attr($attrs, 'class')) ?: [], true);
|
||||
}
|
||||
|
||||
/** Return an attribute's value from a tag's attribute string, '' for a bare attribute, null when absent. */
|
||||
private static function attr(string $attrs, string $name): ?string
|
||||
{
|
||||
if (!preg_match('/(?:^|\s)' . preg_quote($name, '/') . '(?:\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+)))?(?=\s|\/|$)/i', $attrs, $m)) {
|
||||
return null;
|
||||
}
|
||||
return (string) ($m[1] ?? '') . (string) ($m[2] ?? '') . (string) ($m[3] ?? '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/**
|
||||
* Account registry: accounts from config.local.php are authoritative and locked; accounts created in the CMS live in data/users.json.
|
||||
*
|
||||
* Only active CMS accounts and config accounts ever reach Authentication, so blocking or deleting an account also ends its sessions.
|
||||
* Password hashes and invite tokens never leave this class except as the hash handed to Authentication.
|
||||
*/
|
||||
final class UserStore
|
||||
{
|
||||
public const ROLES = ['editor', 'administrator'];
|
||||
public const INVITE_LIFETIME = 604800;
|
||||
public const MIN_PASSWORD = 12;
|
||||
public const MAX_PASSWORD = 72;
|
||||
|
||||
/** Plain-language role descriptions shown on the Users screen. */
|
||||
public const ROLE_INFO = [
|
||||
'editor' => 'Edits content and saves drafts, uploads images, publishes and schedules pages, and restores revisions or deleted pages. Cannot change the site structure or manage users.',
|
||||
'administrator' => 'Everything an editor can do, plus creating, duplicating, renaming and deleting pages, templates, navigation menus, shared content, site scan, deleting media, and managing users.',
|
||||
];
|
||||
|
||||
public function __construct(private FileStore $store, private array $config)
|
||||
{
|
||||
}
|
||||
|
||||
/** Build a store from the site configuration. */
|
||||
public static function fromConfig(array $config): self
|
||||
{
|
||||
$directory = (string) ($config['dataDirectory'] ?? (dirname(__DIR__, 2) . '/data'));
|
||||
return new self(new FileStore($directory), $config);
|
||||
}
|
||||
|
||||
/** Arguments for `new Authentication(...)`: config credentials plus active CMS accounts, roles, and session options. */
|
||||
public static function authArgs(array $config): array
|
||||
{
|
||||
[$credentials, $roles] = self::fromConfig($config)->forAuth();
|
||||
return [$credentials, $roles, $config['security'] ?? []];
|
||||
}
|
||||
|
||||
/** Map an arbitrary configured role onto the two supported roles (the old publisher role is an editor). */
|
||||
public static function normaliseRole(mixed $role): string
|
||||
{
|
||||
return $role === 'administrator' ? 'administrator' : 'editor';
|
||||
}
|
||||
|
||||
/** Credentials and roles of every account allowed to sign in. */
|
||||
public function forAuth(): array
|
||||
{
|
||||
$credentials = [];
|
||||
$roles = [];
|
||||
foreach ($this->data()['users'] as $username => $record) {
|
||||
if (($record['status'] ?? '') === 'active' && is_string($record['hash'] ?? null) && $record['hash'] !== '') {
|
||||
$credentials[$username] = $record['hash'];
|
||||
$roles[$username] = self::normaliseRole($record['role'] ?? 'editor');
|
||||
}
|
||||
}
|
||||
foreach ($this->configAccounts() as $username => $account) {
|
||||
$credentials[$username] = $account['hash'];
|
||||
$roles[$username] = $account['role'];
|
||||
}
|
||||
return [$credentials, $roles];
|
||||
}
|
||||
|
||||
/** The name and email shown for an account, or blanks. */
|
||||
public function profile(string $username): array
|
||||
{
|
||||
foreach ($this->accounts() as $account) {
|
||||
if ($account['username'] === $username) {
|
||||
return ['name' => $account['name'], 'email' => $account['email'], 'managed' => $account['managed']];
|
||||
}
|
||||
}
|
||||
return ['name' => '', 'email' => '', 'managed' => false];
|
||||
}
|
||||
|
||||
/** Every account without secrets: config accounts first (locked), then CMS accounts. */
|
||||
public function accounts(): array
|
||||
{
|
||||
$data = $this->data();
|
||||
$list = [];
|
||||
foreach ($this->configAccounts() as $username => $account) {
|
||||
$profile = $data['profiles'][$username] ?? [];
|
||||
$list[] = [
|
||||
'username' => $username, 'name' => (string) ($profile['name'] ?? ''), 'email' => (string) ($profile['email'] ?? ''),
|
||||
'role' => $account['role'], 'status' => 'active', 'managed' => true, 'created' => '', 'inviteExpired' => false,
|
||||
];
|
||||
}
|
||||
foreach ($data['users'] as $username => $record) {
|
||||
if (isset($this->configAccounts()[$username])) {
|
||||
continue;
|
||||
}
|
||||
$list[] = [
|
||||
'username' => $username, 'name' => (string) ($record['name'] ?? ''), 'email' => (string) ($record['email'] ?? ''),
|
||||
'role' => self::normaliseRole($record['role'] ?? 'editor'), 'status' => (string) ($record['status'] ?? 'active'),
|
||||
'managed' => false, 'created' => (string) ($record['created'] ?? ''),
|
||||
'inviteExpired' => ($record['status'] ?? '') === 'invited' && (int) ($record['invite']['expires'] ?? 0) < time(),
|
||||
];
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function isManaged(string $username): bool
|
||||
{
|
||||
return isset($this->configAccounts()[$username]);
|
||||
}
|
||||
|
||||
/** Set display name and email. Config accounts keep their credentials in config; only this profile overlay is stored. */
|
||||
public function setProfile(string $username, string $name, string $email): void
|
||||
{
|
||||
$name = $this->cleanName($name);
|
||||
$email = $this->cleanEmail($email);
|
||||
$managed = $this->isManaged($username);
|
||||
$this->store->update('users', function (array $data) use ($username, $name, $email, $managed): array {
|
||||
$data = $this->shape($data);
|
||||
$this->assertEmailFree($data, $email, $username);
|
||||
if ($managed) {
|
||||
$data['profiles'][$username] = ['name' => $name, 'email' => $email];
|
||||
} elseif (isset($data['users'][$username])) {
|
||||
$data['users'][$username]['name'] = $name;
|
||||
$data['users'][$username]['email'] = $email;
|
||||
$data['users'][$username]['updated'] = date(DATE_ATOM);
|
||||
} else {
|
||||
throw new \RuntimeException('Account not found');
|
||||
}
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
|
||||
/** Create an active CMS account with a password. */
|
||||
public function create(string $username, string $name, string $email, string $role, string $password): void
|
||||
{
|
||||
$username = trim($username);
|
||||
$name = $this->cleanName($name);
|
||||
$email = $this->cleanEmail($email);
|
||||
$this->assertRole($role);
|
||||
$this->assertPassword($password, $username, $email);
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$this->store->update('users', function (array $data) use ($username, $name, $email, $role, $hash): array {
|
||||
$data = $this->shape($data);
|
||||
$this->assertUsernameFree($data, $username);
|
||||
$this->assertEmailFree($data, $email, $username);
|
||||
$now = date(DATE_ATOM);
|
||||
$data['users'][$username] = ['hash' => $hash, 'role' => $role, 'name' => $name, 'email' => $email, 'status' => 'active', 'created' => $now, 'updated' => $now];
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
|
||||
/** Edit a CMS account; a blank password leaves the current one. Config accounts are refused. */
|
||||
public function update(string $username, string $name, string $email, string $role, string $password = ''): void
|
||||
{
|
||||
$this->assertNotManaged($username);
|
||||
$name = $this->cleanName($name);
|
||||
$email = $this->cleanEmail($email);
|
||||
$this->assertRole($role);
|
||||
$hash = null;
|
||||
if ($password !== '') {
|
||||
$this->assertPassword($password, $username, $email);
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
}
|
||||
$this->store->update('users', function (array $data) use ($username, $name, $email, $role, $hash): array {
|
||||
$data = $this->shape($data);
|
||||
$record = $data['users'][$username] ?? throw new \RuntimeException('Account not found');
|
||||
$this->assertEmailFree($data, $email, $username);
|
||||
$record['name'] = $name;
|
||||
$record['email'] = $email;
|
||||
$record['role'] = $role;
|
||||
if ($hash !== null) {
|
||||
$record['hash'] = $hash;
|
||||
}
|
||||
$record['updated'] = date(DATE_ATOM);
|
||||
$data['users'][$username] = $record;
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
|
||||
/** Change a CMS account's own password. Returns the new hash so the caller can refresh the session. */
|
||||
public function setPassword(string $username, string $password): string
|
||||
{
|
||||
$this->assertNotManaged($username, 'This password is managed in config.local.php');
|
||||
$profile = $this->profile($username);
|
||||
$this->assertPassword($password, $username, $profile['email']);
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$this->store->update('users', function (array $data) use ($username, $hash): array {
|
||||
$data = $this->shape($data);
|
||||
if (!isset($data['users'][$username])) {
|
||||
throw new \RuntimeException('Account not found');
|
||||
}
|
||||
$data['users'][$username]['hash'] = $hash;
|
||||
$data['users'][$username]['updated'] = date(DATE_ATOM);
|
||||
return $data;
|
||||
});
|
||||
return $hash;
|
||||
}
|
||||
|
||||
/** Upgrade a stored hash after a successful login when PHP's default algorithm or cost has changed. */
|
||||
public function rehashIfNeeded(string $username, string $password): void
|
||||
{
|
||||
$record = $this->data()['users'][$username] ?? null;
|
||||
if ($record && !$this->isManaged($username) && is_string($record['hash'] ?? null) && $record['hash'] !== '' && password_needs_rehash($record['hash'], PASSWORD_DEFAULT)) {
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$this->store->update('users', function (array $data) use ($username, $hash): array {
|
||||
$data = $this->shape($data);
|
||||
if (isset($data['users'][$username])) {
|
||||
$data['users'][$username]['hash'] = $hash;
|
||||
}
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function setBlocked(string $username, bool $blocked): void
|
||||
{
|
||||
$this->assertNotManaged($username);
|
||||
$this->store->update('users', function (array $data) use ($username, $blocked): array {
|
||||
$data = $this->shape($data);
|
||||
$record = $data['users'][$username] ?? throw new \RuntimeException('Account not found');
|
||||
if ($blocked) {
|
||||
$record['status'] = 'blocked';
|
||||
} elseif (($record['status'] ?? '') === 'blocked') {
|
||||
$record['status'] = ($record['hash'] ?? '') === '' ? 'invited' : 'active';
|
||||
}
|
||||
$record['updated'] = date(DATE_ATOM);
|
||||
$data['users'][$username] = $record;
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(string $username): void
|
||||
{
|
||||
$this->assertNotManaged($username);
|
||||
$this->store->update('users', function (array $data) use ($username): array {
|
||||
$data = $this->shape($data);
|
||||
if (!isset($data['users'][$username])) {
|
||||
throw new \RuntimeException('Account not found');
|
||||
}
|
||||
unset($data['users'][$username]);
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
|
||||
/** Create (or re-issue) a pending invitation. Returns the username and the one-time token, which is stored only as a hash. */
|
||||
public function invite(string $name, string $email, string $role, string $existing = ''): array
|
||||
{
|
||||
$name = $this->cleanName($name);
|
||||
$email = $this->cleanEmail($email);
|
||||
if ($email === '') {
|
||||
throw new \RuntimeException('An email address is required to invite someone');
|
||||
}
|
||||
$this->assertRole($role);
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$username = '';
|
||||
$this->store->update('users', function (array $data) use ($name, $email, $role, $existing, $tokenHash, &$username): array {
|
||||
$data = $this->shape($data);
|
||||
$now = date(DATE_ATOM);
|
||||
$invite = ['token' => $tokenHash, 'expires' => time() + self::INVITE_LIFETIME];
|
||||
if ($existing !== '') {
|
||||
$record = $data['users'][$existing] ?? null;
|
||||
if (!$record || ($record['status'] ?? '') !== 'invited') {
|
||||
throw new \RuntimeException('Only a pending invitation can be re-issued');
|
||||
}
|
||||
$this->assertEmailFree($data, $email, $existing);
|
||||
$username = $existing;
|
||||
$data['users'][$username] = array_merge($record, ['name' => $name, 'email' => $email, 'role' => $role, 'invite' => $invite, 'updated' => $now]);
|
||||
return $data;
|
||||
}
|
||||
$this->assertEmailFree($data, $email, '');
|
||||
$username = $this->generateUsername($data, $email);
|
||||
$data['users'][$username] = ['hash' => '', 'role' => $role, 'name' => $name, 'email' => $email, 'status' => 'invited', 'invite' => $invite, 'created' => $now, 'updated' => $now];
|
||||
return $data;
|
||||
});
|
||||
return [$username, $token];
|
||||
}
|
||||
|
||||
/** Redeem an invitation: the token is single use and expires. Returns the username. Every failure gives the same message. */
|
||||
public function acceptInvite(string $token, string $password): string
|
||||
{
|
||||
$failure = 'This invitation is invalid or has expired';
|
||||
$wanted = hash('sha256', $token);
|
||||
$accepted = null;
|
||||
$hash = null;
|
||||
$data = $this->data();
|
||||
foreach ($data['users'] as $username => $record) {
|
||||
if (($record['status'] ?? '') === 'invited' && is_string($record['invite']['token'] ?? null)
|
||||
&& hash_equals($record['invite']['token'], $wanted) && (int) ($record['invite']['expires'] ?? 0) >= time()) {
|
||||
$accepted = $username;
|
||||
}
|
||||
}
|
||||
if ($accepted === null || $token === '') {
|
||||
throw new \RuntimeException($failure);
|
||||
}
|
||||
$this->assertPassword($password, $accepted, (string) ($data['users'][$accepted]['email'] ?? ''));
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$this->store->update('users', function (array $data) use ($accepted, $wanted, $hash, $failure): array {
|
||||
$data = $this->shape($data);
|
||||
$record = $data['users'][$accepted] ?? null;
|
||||
if (!$record || ($record['status'] ?? '') !== 'invited' || !hash_equals((string) ($record['invite']['token'] ?? ''), $wanted)) {
|
||||
throw new \RuntimeException($failure);
|
||||
}
|
||||
unset($record['invite']);
|
||||
$record['hash'] = $hash;
|
||||
$record['status'] = 'active';
|
||||
$record['updated'] = date(DATE_ATOM);
|
||||
$data['users'][$accepted] = $record;
|
||||
return $data;
|
||||
});
|
||||
return $accepted;
|
||||
}
|
||||
|
||||
/** Password policy shared by every path that sets a password. */
|
||||
public function assertPassword(string $password, string $username = '', string $email = ''): void
|
||||
{
|
||||
$length = strlen($password);
|
||||
if ($length < self::MIN_PASSWORD) {
|
||||
throw new \RuntimeException('Passwords must be at least ' . self::MIN_PASSWORD . ' characters');
|
||||
}
|
||||
if ($length > self::MAX_PASSWORD) {
|
||||
throw new \RuntimeException('Passwords can be at most ' . self::MAX_PASSWORD . ' bytes long');
|
||||
}
|
||||
$lower = strtolower($password);
|
||||
if (($username !== '' && $lower === strtolower($username)) || ($email !== '' && $lower === strtolower($email))) {
|
||||
throw new \RuntimeException('The password must differ from the login name and email address');
|
||||
}
|
||||
}
|
||||
|
||||
private function configAccounts(): array
|
||||
{
|
||||
$accounts = [];
|
||||
foreach ((array) ($this->config['authentication'] ?? []) as $username => $hash) {
|
||||
if (is_string($username) && is_string($hash) && $hash !== '') {
|
||||
$accounts[$username] = ['hash' => $hash, 'role' => self::normaliseRole(($this->config['roles'] ?? [])[$username] ?? 'editor')];
|
||||
}
|
||||
}
|
||||
return $accounts;
|
||||
}
|
||||
|
||||
private function data(): array
|
||||
{
|
||||
return $this->shape($this->store->read('users'));
|
||||
}
|
||||
|
||||
private function shape(array $data): array
|
||||
{
|
||||
$data['users'] = is_array($data['users'] ?? null) ? $data['users'] : [];
|
||||
$data['profiles'] = is_array($data['profiles'] ?? null) ? $data['profiles'] : [];
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function cleanName(string $name): string
|
||||
{
|
||||
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
|
||||
if ($name === '' || mb_strlen($name) > 80 || preg_match('/[\x00-\x1f\x7f<>]/u', $name)) {
|
||||
throw new \RuntimeException('Enter a display name of up to 80 characters');
|
||||
}
|
||||
return $name;
|
||||
}
|
||||
|
||||
private function cleanEmail(string $email): string
|
||||
{
|
||||
$email = strtolower(trim($email));
|
||||
if ($email !== '' && (strlen($email) > 254 || !filter_var($email, FILTER_VALIDATE_EMAIL))) {
|
||||
throw new \RuntimeException('Enter a valid email address');
|
||||
}
|
||||
return $email;
|
||||
}
|
||||
|
||||
private function assertRole(string $role): void
|
||||
{
|
||||
if (!in_array($role, self::ROLES, true)) {
|
||||
throw new \RuntimeException('Unknown role');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertNotManaged(string $username, string $message = 'This account is managed in config.local.php and cannot be changed here'): void
|
||||
{
|
||||
if ($this->isManaged($username)) {
|
||||
throw new \RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertUsernameFree(array $data, string $username): void
|
||||
{
|
||||
if (!preg_match('/^[a-zA-Z0-9._-]{3,32}$/', $username)) {
|
||||
throw new \RuntimeException('Login names are 3-32 letters, numbers, dots, dashes or underscores');
|
||||
}
|
||||
foreach ([$data['users'], $this->configAccounts()] as $existing) {
|
||||
foreach (array_keys($existing) as $known) {
|
||||
if (strcasecmp((string) $known, $username) === 0) {
|
||||
throw new \RuntimeException('That login name is already in use');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Emails are unique across config overlays and CMS accounts, ignoring case. */
|
||||
private function assertEmailFree(array $data, string $email, string $exceptUsername): void
|
||||
{
|
||||
if ($email === '') {
|
||||
return;
|
||||
}
|
||||
$taken = [];
|
||||
foreach ($data['profiles'] as $username => $profile) {
|
||||
$taken[$username] = strtolower((string) ($profile['email'] ?? ''));
|
||||
}
|
||||
foreach ($data['users'] as $username => $record) {
|
||||
$taken[$username] = strtolower((string) ($record['email'] ?? ''));
|
||||
}
|
||||
foreach ($taken as $username => $known) {
|
||||
if ($known === $email && (string) $username !== $exceptUsername) {
|
||||
throw new \RuntimeException('That email address is already in use');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function generateUsername(array $data, string $email): string
|
||||
{
|
||||
$base = strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', explode('@', $email)[0]) ?? '');
|
||||
$base = substr(str_pad($base, 3, 'x'), 0, 28);
|
||||
for ($i = 0; $i < 1000; $i++) {
|
||||
$candidate = $i === 0 ? $base : $base . $i;
|
||||
try {
|
||||
$this->assertUsernameFree($data, $candidate);
|
||||
return $candidate;
|
||||
} catch (\RuntimeException) {
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException('Unable to generate a login name');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en-gb">
|
||||
<head>
|
||||
<!-- A repeatable-content starter template for listings, teams, services, and similar pages. -->
|
||||
<meta charset="UTF-8">
|
||||
<title>Welcome to NeoCMS</title>
|
||||
<link href="/cms/css/welcome.style.css" rel="stylesheet">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>New NeoCMS Listing</title>
|
||||
<meta name="description" content="Describe this listing for visitors and search engines.">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<img alt="Application Logo" class="logo" src="/cms/img/welcomeLogo.png">
|
||||
<header class="editable">
|
||||
<h1>New listing</h1>
|
||||
<p>Edit this introduction, then duplicate the example item below as required.</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<h1>Welcome to NeoCMS</h1>
|
||||
<p>This is another example template, found in /cms/templates/ - remove me and replace me with your own site
|
||||
templates!</p>
|
||||
<p class="editable">
|
||||
This box is editable!<br/>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
Quisque sit amet accumsan tortor. Itaque earum rerum hic tenetur a sapiente delectus.
|
||||
</p>
|
||||
<!-- neo-dupe adds clone-before, clone-after, and delete controls in the CMS preview. -->
|
||||
<article class="editable neo-dupe">
|
||||
<h2>Example item</h2>
|
||||
<p>Replace this content or duplicate the complete item.</p>
|
||||
</article>
|
||||
</main>
|
||||
<footer>
|
||||
<p>© 2024 Andy Dixon</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,26 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en-gb">
|
||||
<head>
|
||||
<!-- Replace these metadata values through the CMS SEO dialogue after creating the page. -->
|
||||
<meta charset="UTF-8">
|
||||
<title>Welcome to NeoCMS</title>
|
||||
<link href="/cms/css/welcome.style.css" rel="stylesheet">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>New NeoCMS Page</title>
|
||||
<meta name="description" content="Describe this page for visitors and search engines.">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<img alt="Application Logo" class="logo" src="/cms/img/welcomeLogo.png">
|
||||
</header>
|
||||
<main>
|
||||
<h1>Welcome to NeoCMS</h1>
|
||||
<p>This is an example template, found in /cms/templates/ - remove me and replace me with your own site
|
||||
templates!</p>
|
||||
<p class="editable">
|
||||
This box is editable!<br/>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
Quisque sit amet accumsan tortor. Itaque earum rerum hic tenetur a sapiente delectus.
|
||||
</p>
|
||||
<!-- The default editable class may be changed with editableClass in cms/config.php. -->
|
||||
<main class="editable">
|
||||
<h1>New page</h1>
|
||||
<p>Select this region in NeoCMS to replace the example content.</p>
|
||||
</main>
|
||||
<footer>
|
||||
<p>© 2024 Andy Dixon</p>
|
||||
|
||||
<!-- Shared keys allow one saved block to update every matching page. -->
|
||||
<footer class="editable" data-neo-shared="site-footer">
|
||||
<p>Shared site footer</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
# TinyMCE
|
||||
|
||||
The world's #1 open source rich text editor.
|
||||
|
||||
**Using an old version of TinyMCE?** We recommend you to upgrade to TinyMCE 8 to continue receiving security updates.
|
||||
|
||||
Used and trusted by millions of developers, TinyMCE is the world’s most customizable, scalable, and flexible rich text editor. We’ve helped launch the likes of Atlassian, Medium, Evernote (and lots more that we can’t tell you), by empowering them to create exceptional content and experiences for their users.
|
||||
|
||||
With more than 350M+ downloads every year, we’re also one of the most trusted enterprise-grade open source HTML editors on the internet. There’s currently more than 100M+ products worldwide, powered by Tiny. As a high powered WYSIWYG editor, TinyMCE is built to scale, designed to innovate, and thrives on delivering results to difficult edge-cases.
|
||||
|
||||
You can access a [full featured demo of TinyMCE](https://www.tiny.cloud/docs/tinymce/8/premium-full-featured/) in the docs on the TinyMCE website.
|
||||
|
||||
<p align="center">
|
||||
<img alt="Screenshot of the TinyMCE Editor" src="https://www.tiny.cloud/storage/github-readme-images/tinymce-editor-6x.png"\>
|
||||
</p>
|
||||
|
||||
## Get started with TinyMCE
|
||||
|
||||
Getting started with the TinyMCE rich text editor is easy, and for simple configurations can be done in less than 5 minutes.
|
||||
|
||||
[TinyMCE Cloud Deployment Quick Start Guide](https://www.tiny.cloud/docs/tinymce/8/cloud-quick-start/)
|
||||
|
||||
[TinyMCE Self-hosted Deployment Guide](https://www.tiny.cloud/docs/tinymce/8/npm-projects/)
|
||||
|
||||
TinyMCE provides a range of configuration options that allow you to integrate it into your application. Start customizing with a [basic setup](https://www.tiny.cloud/docs/tinymce/8/basic-setup/).
|
||||
|
||||
Configure it for one of three modes of editing:
|
||||
|
||||
- [TinyMCE classic editing mode](https://www.tiny.cloud/docs/tinymce/8/use-tinymce-classic/).
|
||||
- [TinyMCE inline editing mode](https://www.tiny.cloud/docs/tinymce/8/use-tinymce-inline/).
|
||||
- [TinyMCE distraction-free editing mode](https://www.tiny.cloud/docs/tinymce/8/use-tinymce-distraction-free/).
|
||||
|
||||
## Features
|
||||
|
||||
### Integration
|
||||
|
||||
TinyMCE is easily integrated into your projects with the help of components such as:
|
||||
|
||||
- [tinymce-react](https://github.com/tinymce/tinymce-react)
|
||||
- [tinymce-vue](https://github.com/tinymce/tinymce-vue)
|
||||
- [tinymce-angular](https://github.com/tinymce/tinymce-angular)
|
||||
|
||||
With over 29 integrations, and 400+ APIs, see the TinyMCE docs for a full list of editor [integrations](https://www.tiny.cloud/docs/tinymce/8/integrations/).
|
||||
|
||||
### Customization
|
||||
|
||||
It is easy to [configure the UI](https://www.tiny.cloud/docs/tinymce/8/customize-ui/) of your rich text editor to match the design of your site, product or application. Due to its flexibility, you can [configure the editor](https://www.tiny.cloud/docs/tinymce/8/basic-setup/) with as much or as little functionality as you like, depending on your requirements.
|
||||
|
||||
With [50+ powerful plugins available](https://www.tiny.cloud/tinymce/features/), and content editable as the basis of TinyMCE, adding additional functionality is as simple as including a single line of code.
|
||||
|
||||
Realizing the full power of most plugins requires only a few lines more.
|
||||
|
||||
### Extensibility
|
||||
|
||||
Sometimes your editor requirements can be quite unique, and you need the freedom and flexibility to innovate. Thanks to TinyMCE being open source, you can view the source code and develop your own extensions for custom functionality to meet your own requirements.
|
||||
|
||||
The TinyMCE [API](https://www.tiny.cloud/docs/tinymce/8/apis/tinymce.root/) is exposed to make it easier for you to write custom functionality that fits within the existing framework of TinyMCE [UI components](https://www.tiny.cloud/docs/tinymce/8/custom-ui-components/).
|
||||
|
||||
### Extended Features and Support
|
||||
|
||||
For the professional software teams that require more in-depth efficiency, compliance or collaborative features built to enterprise-grade standards, please [get in touch with our team](https://www.tiny.cloud/contact/).
|
||||
|
||||
Tiny also offers dedicated SLAs and support for professional development teams.
|
||||
|
||||
## Compiling and contributing
|
||||
|
||||
In 2019 the decision was made to transition our codebase to a monorepo. For information on compiling and contributing, see: [contribution guidelines](https://github.com/tinymce/tinymce/blob/master/CONTRIBUTING.md).
|
||||
|
||||
As an open source product, we encourage and support the active development of our software.
|
||||
|
||||
## Want more information?
|
||||
|
||||
Visit the [TinyMCE website](https://tiny.cloud/) and check out the [TinyMCE documentation](https://www.tiny.cloud/docs/).
|
||||
|
||||
## License
|
||||
|
||||
License terms can be found in the license.md file.
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "tinymce",
|
||||
"description": "Web based JavaScript HTML WYSIWYG editor control.",
|
||||
"license": "SEE LICENSE IN license.md",
|
||||
"keywords": [
|
||||
"wysiwyg",
|
||||
"tinymce",
|
||||
"richtext",
|
||||
"javascript",
|
||||
"html",
|
||||
"text",
|
||||
"rich editor",
|
||||
"rich text editor",
|
||||
"rte",
|
||||
"rich text",
|
||||
"contenteditable",
|
||||
"editing"
|
||||
],
|
||||
"homepage": "https://www.tiny.cloud/",
|
||||
"ignore": [
|
||||
"README.md",
|
||||
"composer.json",
|
||||
"package.json",
|
||||
".npmignore",
|
||||
"CHANGELOG.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "tinymce/tinymce",
|
||||
"version": "8.5.1",
|
||||
"description": "Web based JavaScript HTML WYSIWYG editor control.",
|
||||
"license": [
|
||||
"SEE LICENSE IN license.md"
|
||||
],
|
||||
"keywords": [
|
||||
"wysiwyg",
|
||||
"tinymce",
|
||||
"richtext",
|
||||
"javascript",
|
||||
"html",
|
||||
"text",
|
||||
"rich editor",
|
||||
"rich text editor",
|
||||
"rte",
|
||||
"rich text",
|
||||
"contenteditable",
|
||||
"editing"
|
||||
],
|
||||
"homepage": "https://www.tiny.cloud/",
|
||||
"type": "component",
|
||||
"extra": {
|
||||
"component": {
|
||||
"scripts": [
|
||||
"tinymce.js",
|
||||
"plugins/*/plugin.js",
|
||||
"themes/*/theme.js",
|
||||
"models/*/model.js",
|
||||
"icons/*/icons.js"
|
||||
],
|
||||
"files": [
|
||||
"tinymce.min.js",
|
||||
"plugins/*/plugin.min.js",
|
||||
"themes/*/theme.min.js",
|
||||
"models/*/model.min.js",
|
||||
"skins/**",
|
||||
"icons/*/icons.min.js"
|
||||
]
|
||||
}
|
||||
},
|
||||
"archive": {
|
||||
"exclude": [
|
||||
"README.md",
|
||||
"bower.js",
|
||||
"package.json",
|
||||
".npmignore",
|
||||
"CHANGELOG.md"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
tinymce.IconManager.add('default', {
|
||||
icons: {
|
||||
'accessibility-check': '<svg width="24" height="24"><path d="M12 2a2 2 0 0 1 2 2 2 2 0 0 1-2 2 2 2 0 0 1-2-2c0-1.1.9-2 2-2Zm8 7h-5v12c0 .6-.4 1-1 1a1 1 0 0 1-1-1v-5c0-.6-.4-1-1-1a1 1 0 0 0-1 1v5c0 .6-.4 1-1 1a1 1 0 0 1-1-1V9H4a1 1 0 1 1 0-2h16c.6 0 1 .4 1 1s-.4 1-1 1Z" fill-rule="nonzero"/></svg>',
|
||||
'accordion-toggle': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 15c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1h-6a1 1 0 0 1-1-1Z"/><path opacity=".2" fill-rule="evenodd" clip-rule="evenodd" d="M4 15c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1-1-1Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12 19c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1h-6a1 1 0 0 1-1-1Z"/><path opacity=".2" fill-rule="evenodd" clip-rule="evenodd" d="M4 19c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1-1-1Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12.3 7.3a1 1 0 0 1 1.4 0L16 9.6l2.3-2.3a1 1 0 1 1 1.4 1.4L16 12.4l-3.7-3.7a1 1 0 0 1 0-1.4ZM4.3 11.7a1 1 0 0 1 0-1.4L6.6 8 4.3 5.7a1 1 0 0 1 1.4-1.4L9.4 8l-3.7 3.7a1 1 0 0 1-1.4 0Z"/></svg>',
|
||||
'accordion': '<svg width="24" height="24"><rect x="12" y="7" width="10" height="2" rx="1"/><rect x="12" y="11" width="10" height="2" rx="1"/><rect x="12" y="15" width="6" height="2" rx="1"/><path fill-rule="evenodd" clip-rule="evenodd" d="M2.3 7.3a1 1 0 0 1 1.4 0L6 9.6l2.3-2.3a1 1 0 0 1 1.4 1.4L6 12.4 2.3 8.7a1 1 0 0 1 0-1.4Z"/></svg>',
|
||||
'action-next': '<svg width="24" height="24"><path fill-rule="nonzero" d="M5.7 7.3a1 1 0 0 0-1.4 1.4l7.7 7.7 7.7-7.7a1 1 0 1 0-1.4-1.4L12 13.6 5.7 7.3Z"/></svg>',
|
||||
'action-prev': '<svg width="24" height="24"><path fill-rule="nonzero" d="M18.3 15.7a1 1 0 0 0 1.4-1.4L12 6.6l-7.7 7.7a1 1 0 0 0 1.4 1.4L12 9.4l6.3 6.3Z"/></svg>',
|
||||
'add-file': '<svg height="24" width="24"><path d="M2 7h2V4h3V2H4a2 2 0 0 0-2 2zm20 0h-2V4h-3V2h3a2 2 0 0 1 2 2zm0 2h-2v6h2zm0 8h-2v3h-3v2h3a2 2 0 0 0 2-2zM2 9h2v6H2zm0 8h2v3h3v2H4a2 2 0 0 1-2-2zm7 5v-2h6v2zm6-20v2H9V2zM6 17h12l-4-5-3 3.8-2-2.6z"/><path d="M2 7h2V4h3V2H4a2 2 0 0 0-2 2zm20 0h-2V4h-3V2h3a2 2 0 0 1 2 2zm0 2h-2v6h2zm0 8h-2v3h-3v2h3a2 2 0 0 0 2-2zM2 9h2v6H2zm0 8h2v3h3v2H4a2 2 0 0 1-2-2zm7 5v-2h6v2zm6-20v2H9V2zM6 17h12l-4-5-3 3.8-2-2.6z"/><path d="M6 17h12l-4-5-3 3.8-2-2.6z"/><path d="M6 17h12l-4-5-3 3.8-2-2.6z"/><path d="M6 17h12l-4-5-3 3.8-2-2.6z"/><path d="M6 17h12l-4-5-3 3.8-2-2.6z"/></svg>',
|
||||
'addtag': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M15 5a2 2 0 0 1 1.6.8L21 12l-4.4 6.2a2 2 0 0 1-1.6.8h-3v-2h3l3.5-5L15 7H5v3H3V7c0-1.1.9-2 2-2h10Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6 12a1 1 0 0 0-1 1v2H3a1 1 0 1 0 0 2h2v2a1 1 0 1 0 2 0v-2h2a1 1 0 1 0 0-2H7v-2c0-.6-.4-1-1-1Z"/></svg>',
|
||||
'adjust-length': '<svg width="24" height="24"><path d="M14 7c0 .9.5 1.8 1.5 2.2h.2c.5.3.9.7 1.1 1.3v.1l.1.1v.1h.1V14h-2V9h-2v5h-2V9H9v5H7V9H4v9h16v-6.2a2.3 2.3 0 0 0 1-1l.1-.1v-.2h.1c.2-.5.4-.8.8-1V18c0 .6-.2 1-.6 1.4a2 2 0 0 1-1.4.6H4a2 2 0 0 1-1.4-.6A2 2 0 0 1 2 18V9c0-.6.2-1 .6-1.4A2 2 0 0 1 4 7h10Z"/><path d="M18.7 4.2a.3.3 0 0 1 .6 0 4.1 4.1 0 0 0 2.5 2.5.3.3 0 0 1 0 .6l-.2.1a4.1 4.1 0 0 0-2.3 2.4c-.1.3-.5.3-.6 0a4.1 4.1 0 0 0-.6-1 4.2 4.2 0 0 0-1.7-1.4h-.2a.3.3 0 0 1 0-.7 4.1 4.1 0 0 0 2.5-2.5Z"/></svg>',
|
||||
'adjustments': '<svg width="24" height="24"><path d="M16 11a3 3 0 1 1 2.8-4H21v2h-2.2a3 3 0 0 1-2.8 2Zm0-2a1 1 0 1 1 0-2 1 1 0 0 1 0 2ZM3 9h8V7H3v2Zm5 10a3 3 0 1 0-2.8-4H3v2h2.2A3 3 0 0 0 8 19Zm0-2a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm13 0h-8v-2h8v2Z"/></svg>',
|
||||
'ai-assistant': '<svg height="24" width="24"><path d="m15.4 2.6-1.9 1.9h-4a5.5 5.5 0 1 0 0 11H13V18c2.6-2 6.8-4 7-7.6V10l1.8-1.8.2 1.8c0 5.7-7 9-11 12v-4.5H9.5a7.5 7.5 0 0 1 0-15h6z"/><path d="M8.5 8.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm7 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm3.2-6.3a.3.3 0 0 1 .6 0 4.1 4.1 0 0 0 2.5 2.5c.3 0 .3.5 0 .6l-.2.1a4.1 4.1 0 0 0-2.3 2.4c-.1.3-.5.3-.6 0a4.1 4.1 0 0 0-2.3-2.4h-.2a.3.3 0 0 1 0-.7 4.1 4.1 0 0 0 2.5-2.5z"/></svg>',
|
||||
'ai-chat-response': '<svg width="24" height="24"><path d="M9 9a7.1 7.1 0 0 0 6 6 7.1 7.1 0 0 0-6 6 7.1 7.1 0 0 0-6-6 7.1 7.1 0 0 0 6-6Zm7.5-5A4.6 4.6 0 0 0 21 8.5a4.6 4.6 0 0 0-4.5 4.5A4.6 4.6 0 0 0 12 8.5 4.6 4.6 0 0 0 16.5 4ZM6 3a5 5 0 0 0 3 3 5 5 0 0 0-3 3 5 5 0 0 0-3-3 5 5 0 0 0 3-3Z"/></svg>',
|
||||
'ai-model': '<svg height="24" width="24"><path d="M12.1 3c.5 0 .9.5.9 1v3h5a2 2 0 0 1 2 2v4h2v2h-2v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-4H2v-2h2V9c0-1.1.9-2 2-2h5V5H7.4V3H12zM6 19h12V9H6zm3-7c.6 0 1 .4 1 1v2a1 1 0 1 1-2 0v-2c0-.6.4-1 1-1zm6 0c.6 0 1 .4 1 1v2a1 1 0 1 1-2 0v-2c0-.6.4-1 1-1z"/></svg>',
|
||||
'ai-prompt': '<svg width="24" height="24"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M15 6.7a1 1 0 0 0-1.4 0l-9.9 10a1 1 0 0 0 0 1.3l2.1 2.1c.4.4 1 .4 1.4 0l10-9.9c.3-.3.3-1 0-1.4l-2.2-2Zm1.4 2.8-2-2-3 2.7 2.2 2.2 2.8-2.9Z"/><path d="m18.5 7.3-.7-1.5-1.5-.8 1.5-.7.7-1.5.7 1.5 1.5.7-1.5.8-.7 1.5ZM18.5 16.5l-.7-1.6-1.5-.7 1.5-.7.7-1.6.7 1.6 1.5.7-1.5.7-.7 1.6ZM9.7 7.3 9 5.8 7.5 5 9 4.3l.7-1.5.7 1.5L12 5l-1.5.8-.7 1.5Z"/></g><defs><clipPath id="a"><path d="M0 0h24v24H0z"/></clipPath></defs></svg>',
|
||||
'ai-review': '<svg height="24" width="24"><path d="M15.4 12.6zM15.3 12.6h.1l.3-.2a.5.5 0 0 0 0-.8 5.5 5.5 0 0 1-3.3-3.3.5.5 0 0 0-.8 0 5.5 5.5 0 0 1-3.3 3.3.5.5 0 0 0 0 .8l.3.1a5.5 5.5 0 0 1 2.7 2.6l.1.2.2.4a.5.5 0 0 0 .8 0l.1-.2v-.2l.2-.2a5.5 5.5 0 0 1 2.6-2.5zM12 11a7.3 7.3 0 0 1-1 .9l1 1a7.3 7.3 0 0 1 1-1 7.3 7.3 0 0 1-1-1z" fill-rule="evenodd"/><path d="M12 4a10 10 0 0 1 1.5.1l-.3 2A7.6 7.6 0 0 0 12 6c-1.7 0-3.2.5-4.5 1.5-1.4 1-2.6 2.4-3.4 4a1 1 0 0 0-.1.5 1 1 0 0 0 0 .3l.1.1c.8 1.7 2 3 3.4 4.1a7.5 7.5 0 0 0 5.3 1.5c1.4-.2 2.6-.7 3.7-1.5 1.2-.9 2.2-2 3-3.4a13.5 13.5 0 0 0 .4-.7 1 1 0 0 0 .1-.4 1 1 0 0 0-.1-.4 12.5 12.5 0 0 0-.7-1.3l1.6-1.1a13.7 13.7 0 0 1 .9 1.5 3 3 0 0 1 .2 2 3 3 0 0 1-.2.6 15.1 15.1 0 0 1-.4.8c-1 1.6-2.1 3-3.6 4-1.4 1-3 1.7-4.7 1.8a9.9 9.9 0 0 1-1 .1c-2 0-4-.6-5.7-1.9-1.7-1.2-3-2.8-4-4.8a3 3 0 0 1 0-2.6c1-2 2.3-3.6 4-4.8A9.5 9.5 0 0 1 12 4z"/><path d="M16.7 4.2a.3.3 0 0 1 .6 0 4.1 4.1 0 0 0 2.5 2.5.3.3 0 0 1 0 .6l-.2.1a4.1 4.1 0 0 0-2.3 2.4c-.1.3-.5.3-.6 0a4.1 4.1 0 0 0-2.3-2.4h-.2a.3.3 0 0 1 0-.7 4.1 4.1 0 0 0 2.5-2.5z"/></svg>',
|
||||
'ai-translate': '<svg height="24" width="24"><path d="M22 22h-2.1l-1-3h-5l-1 3h-2.1l4.7-12h2zm-7.4-4.8h3.6l-1.8-5.1z" fill-rule="evenodd"/><path d="M8.9 4h5.2s-.8 2.5-2.7 5.7c-.7 1.2-2 2.9-2 2.9l2.4 2.5-1 1.8-2.9-3-4.5 4.6L2 17.1l4.6-4.5-1.6-2C4.6 9.9 4 9 3.7 8h2A15.3 15.3 0 0 0 8 11.1l1.7-2.3c.7-1 1-1.9 1.3-2.8H2V4h4.9V2h2zm9.3-1.8a.3.3 0 0 1 .6 0 4.1 4.1 0 0 0 2.5 2.5.3.3 0 0 1 0 .6l-.2.1a4.1 4.1 0 0 0-2.3 2.4c-.1.3-.5.3-.6 0a4.1 4.1 0 0 0-2.3-2.4h-.2a.3.3 0 0 1 0-.7 4.1 4.1 0 0 0 2.5-2.5z"/></svg>',
|
||||
'ai': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M5 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3H5Zm6.8 11.5.5 1.2a68.3 68.3 0 0 0 .7 1.1l.4.1c.3 0 .5 0 .7-.3.2-.1.3-.3.3-.6l-.3-1-2.6-6.2a20.4 20.4 0 0 0-.5-1.3l-.5-.4-.7-.2c-.2 0-.5 0-.6.2-.2 0-.4.2-.5.4l-.3.6-.3.7L5.7 15l-.2.6-.1.4c0 .3 0 .5.3.7l.6.2c.3 0 .5 0 .7-.2l.4-1 .5-1.2h3.9ZM9.8 9l1.5 4h-3l1.5-4Zm5.6-.9v7.6c0 .4 0 .7.2 1l.7.2c.3 0 .6 0 .8-.3l.2-.9V8.1c0-.4 0-.7-.2-.9a1 1 0 0 0-.8-.3c-.2 0-.5.1-.7.3l-.2 1Z"/></svg>',
|
||||
'align-center': '<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm3 4h8c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 1 1 0-2Zm0 8h8c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 0 1 0-2Zm-3-4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',
|
||||
'align-justify': '<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Zm0 4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',
|
||||
'align-left': '<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 4h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 8h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Zm0-4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',
|
||||
'align-none': '<svg width="24" height="24"><path d="M14.2 5 13 7H5a1 1 0 1 1 0-2h9.2Zm4 0h.8a1 1 0 0 1 0 2h-2l1.2-2Zm-6.4 4-1.2 2H5a1 1 0 0 1 0-2h6.8Zm4 0H19a1 1 0 0 1 0 2h-4.4l1.2-2Zm-6.4 4-1.2 2H5a1 1 0 0 1 0-2h4.4Zm4 0H19a1 1 0 0 1 0 2h-6.8l1.2-2ZM7 17l-1.2 2H5a1 1 0 0 1 0-2h2Zm4 0h8a1 1 0 0 1 0 2H9.8l1.2-2Zm5.2-13.5 1.3.7-9.7 16.3-1.3-.7 9.7-16.3Z" fill-rule="evenodd"/></svg>',
|
||||
'align-right': '<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm6 4h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0 8h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm-6-4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',
|
||||
'alt-text': '<svg width="24" height="24"><path d="M3 20a2 2 0 0 1-1.4-.6A2 2 0 0 1 1 18V6c0-.6.2-1 .6-1.4A2 2 0 0 1 3 4h18c.6 0 1 .2 1.4.6.4.4.6.9.6 1.4v12c0 .6-.2 1-.6 1.4a2 2 0 0 1-1.4.6H3Zm0-2h18V6H3v12Zm1.5-3H6v-1.5h1.5V15H9v-5a1 1 0 0 0-.3-.7A1 1 0 0 0 8 9H5.5a1 1 0 0 0-.7.3 1 1 0 0 0-.3.7v5ZM6 12v-1.5h1.5V12H6Z"/><path d="M11 15V9h1.3v4.5h2V15H11Zm5.8-4.5V15h1.4v-4.5h1.3V9h-4v1.5h1.3Z"/></svg>',
|
||||
'arrow-left': '<svg width="24" height="24"><path d="m5.6 13 12 6a1 1 0 0 0 1.4-1V6a1 1 0 0 0-1.4-.9l-12 6a1 1 0 0 0 0 1.8Z" fill-rule="evenodd"/></svg>',
|
||||
'arrow-right': '<svg width="24" height="24"><path d="m18.5 13-12 6A1 1 0 0 1 5 18V6a1 1 0 0 1 1.4-.9l12 6a1 1 0 0 1 0 1.8Z" fill-rule="evenodd"/></svg>',
|
||||
'attachment': '<svg height="24" width="24"><path d="M11 2c1 0 2 .5 2.8 1.3.7.8 1.1 1.9 1.1 3L15 16c0 .7-.2 1.3-.7 1.8-.4.5-1 .8-1.8.8-.6 0-1.3-.3-1.7-.8-.5-.5-.7-1.1-.7-1.8L10 6.3h2V16l.2.5.3.1.4-.1.1-.5V6.3c0-.7-.3-1.3-.7-1.7-.3-.4-.8-.6-1.3-.6s-1 .2-1.4.6c-.4.4-.6 1-.6 1.7V16c0 1 .5 2 1.1 2.8.7.7 1.6 1.1 2.4 1.1 1 0 1.8-.4 2.5-1.1.6-.7 1-1.7 1-2.8l-.1-9h2l.1 9c0 1.5-.6 3-1.6 4.1a5.3 5.3 0 0 1-3.9 1.8c-1.4 0-2.8-.6-3.8-1.8A6.2 6.2 0 0 1 7 16.1L7 6.3V6c0-1 .4-2 1.1-2.8C9 2.5 10 2 11 2z"/></svg>',
|
||||
'auto-image-enhancement': '<svg width="24" height="24"><path d="M7.7 10.6a5.8 5.8 0 0 0 5.7 5.7A5.8 5.8 0 0 0 7.7 22 5.8 5.8 0 0 0 2 16.3a5.8 5.8 0 0 0 5.7-5.7ZM15.5 2c.3 3 3.3 6 6.5 6.4-3.2.5-6.2 3.4-6.5 6.5-.3-3.4-3-6-6.4-6.5 3.3-.5 6.1-3 6.4-6.4Z"/></svg>',
|
||||
'blur': '<svg width="24" height="24"><path d="M19.3 9.3a1 1 0 0 0-.3.7c0 .3.1.5.3.7.2.2.4.3.7.3s.5-.1.7-.3a1 1 0 0 0 .3-.7 1 1 0 0 0-.3-.7A1 1 0 0 0 20 9a1 1 0 0 0-.7.3Zm-16 0a1 1 0 0 0-.3.7c0 .3.1.5.3.7.2.2.4.3.7.3s.5-.1.7-.3A1 1 0 0 0 5 10a1 1 0 0 0-.3-.7A1 1 0 0 0 4 9a1 1 0 0 0-.7.3Zm16 6a1 1 0 0 0-.3.7c0 .3.1.5.3.7.2.2.4.3.7.3s.5-.1.7-.3a1 1 0 0 0 .3-.7 1 1 0 0 0-.3-.7 1 1 0 0 0-.7-.3 1 1 0 0 0-.7.3Zm-16 0a1 1 0 0 0-.3.7c0 .3.1.5.3.7.2.2.4.3.7.3s.5-.1.7-.3A1 1 0 0 0 5 16a1 1 0 0 0-.3-.7A1 1 0 0 0 4 15a1 1 0 0 0-.7.3Zm5-11A1 1 0 0 0 8 5a1 1 0 0 0 1 1c.3 0 .5-.1.7-.3A1 1 0 0 0 10 5a1 1 0 0 0-.3-.7A1 1 0 0 0 9 4a1 1 0 0 0-.7.3Zm6 0a1 1 0 0 0-.3.7 1 1 0 0 0 1 1c.3 0 .5-.1.7-.3A1 1 0 0 0 16 5a1 1 0 0 0-.3-.7A1 1 0 0 0 15 4a1 1 0 0 0-.7.3Zm-6 16a1 1 0 0 0-.3.7c0 .3.1.5.3.7.2.2.4.3.7.3s.5-.1.7-.3a1 1 0 0 0 .3-.7 1 1 0 0 0-.3-.7A1 1 0 0 0 9 20a1 1 0 0 0-.7.3Zm6 0a1 1 0 0 0-.3.7c0 .3.1.5.3.7.2.2.4.3.7.3s.5-.1.7-.3a1 1 0 0 0 .3-.7 1 1 0 0 0-.3-.7 1 1 0 0 0-.7-.3 1 1 0 0 0-.7.3ZM7.6 8.6A2 2 0 0 0 7 10c0 .6.2 1 .6 1.4.4.4.8.6 1.4.6s1-.2 1.4-.6c.4-.4.6-.8.6-1.4s-.2-1-.6-1.4A2 2 0 0 0 9 8a2 2 0 0 0-1.4.6Zm0 6A2 2 0 0 0 7 16c0 .6.2 1 .6 1.4.4.4.8.6 1.4.6s1-.2 1.4-.6c.4-.4.6-.8.6-1.4s-.2-1-.6-1.4A2 2 0 0 0 9 14a2 2 0 0 0-1.4.6Zm6-6A2 2 0 0 0 13 10c0 .6.2 1 .6 1.4.4.4.8.6 1.4.6s1-.2 1.4-.6c.4-.4.6-.8.6-1.4s-.2-1-.6-1.4A2 2 0 0 0 15 8a2 2 0 0 0-1.4.6Zm0 6A2 2 0 0 0 13 16c0 .6.2 1 .6 1.4.4.4.8.6 1.4.6s1-.2 1.4-.6c.4-.4.6-.8.6-1.4s-.2-1-.6-1.4A2 2 0 0 0 15 14a2 2 0 0 0-1.4.6Z"/></svg>',
|
||||
'bold': '<svg width="24" height="24"><path d="M7.8 19c-.3 0-.5 0-.6-.2l-.2-.5V5.7c0-.2 0-.4.2-.5l.6-.2h5c1.5 0 2.7.3 3.5 1 .7.6 1.1 1.4 1.1 2.5a3 3 0 0 1-.6 1.9c-.4.6-1 1-1.6 1.2.4.1.9.3 1.3.6s.8.7 1 1.2c.4.4.5 1 .5 1.6 0 1.3-.4 2.3-1.3 3-.8.7-2.1 1-3.8 1H7.8Zm5-8.3c.6 0 1.2-.1 1.6-.5.4-.3.6-.7.6-1.3 0-1.1-.8-1.7-2.3-1.7H9.3v3.5h3.4Zm.5 6c.7 0 1.3-.1 1.7-.4.4-.4.6-.9.6-1.5s-.2-1-.7-1.4c-.4-.3-1-.4-2-.4H9.4v3.8h4Z" fill-rule="evenodd"/></svg>',
|
||||
'bookmark': '<svg width="24" height="24"><path d="M6 4v17l6-4 6 4V4c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1Z" fill-rule="nonzero"/></svg>',
|
||||
'border-style': '<svg width="24" height="24"><g fill-rule="evenodd"><rect width="18" height="2" x="3" y="6" rx="1"/><rect width="2.8" height="2" x="3" y="16" rx="1"/><rect width="2.8" height="2" x="6.8" y="16" rx="1"/><rect width="2.8" height="2" x="10.6" y="16" rx="1"/><rect width="2.8" height="2" x="14.4" y="16" rx="1"/><rect width="2.8" height="2" x="18.2" y="16" rx="1"/><rect width="8" height="2" x="3" y="11" rx="1"/><rect width="8" height="2" x="13" y="11" rx="1"/></g></svg>',
|
||||
'border-width': '<svg width="24" height="24"><g fill-rule="evenodd"><rect width="18" height="5" x="3" y="5" rx="1"/><rect width="18" height="3.5" x="3" y="11.5" rx="1"/><rect width="18" height="2" x="3" y="17" rx="1"/></g></svg>',
|
||||
'box': '<svg width="24" height="24"><path d="M3.8 7a1 1 0 0 0-.7.6l-.1 3V13.8a3.5 3.5 0 0 0 4 2.7c.8-.2 1.4-.5 2-1l.3-.3.3.2c.7.7 1.4 1 2.4 1.1 1.2 0 2.4-.6 3-1.6.9-1.2.8-2.8 0-4a3.7 3.7 0 0 0-2-1.2c-.4-.1-1.2-.1-1.6 0a3.4 3.4 0 0 0-1.7 1l-.3.2-.2-.1c-.4-.5-1.2-1-2-1.1-.6-.2-1.5 0-2 .2H5V9c0-1.3 0-1.3-.3-1.6a1 1 0 0 0-1-.3Zm12 2.6c-.3.2-.5.4-.6.8v.6l.9 1 .7 1v.1l-.8 1-.7 1a1 1 0 0 0 0 .7 1 1 0 0 0 1.3.6c.2 0 .3-.2 1-1l.5-.7.6.8.7.8c.4.3 1 .3 1.3 0l.2-.3a1 1 0 0 0 0-.8l-.8-1.2-.7-1a26 26 0 0 0 1.5-2 .8.8 0 0 0 .1-.4c0-.4-.1-.7-.5-.9H20c-.4 0-.5 0-1.2.8l-.7.8-.6-.7-.7-.9a1 1 0 0 0-1 0Zm-9 2 .4.1c.3.1.6.5.7.8l.1.6v.6c-.6 1-2 1.2-2.7.3-.3-.3-.3-.5-.3-1v-.5c.3-.5.7-.8 1.1-1a1.5 1.5 0 0 1 .7 0Zm5.7 0c.4 0 .8.4 1 .8l.2.6-.1.7c-.5.9-1.6 1.1-2.4.6a1.4 1.4 0 0 1-.6-1.1 1.4 1.4 0 0 1 .5-1.2 1.5 1.5 0 0 1 1.4-.4Z"/></svg>',
|
||||
'brightness': '<svg width="24" height="24"><path d="M12.7 18.3a1 1 0 0 0-.7-.3 1 1 0 0 0-.7.3 1 1 0 0 0-.3.7v1a1 1 0 0 0 .3.7 1 1 0 0 0 .7.3 1 1 0 0 0 .7-.3 1 1 0 0 0 .3-.7v-1a1 1 0 0 0-.3-.7ZM11.3 5.7a1 1 0 0 0 .7.3 1 1 0 0 0 .7-.3A1 1 0 0 0 13 5V4a1 1 0 0 0-.3-.7A1 1 0 0 0 12 3a1 1 0 0 0-.7.3 1 1 0 0 0-.3.7v1a1 1 0 0 0 .3.7Zm9.4 5.6a1 1 0 0 0-.7-.3h-1a1 1 0 0 0-.7.3 1 1 0 0 0-.3.7 1 1 0 0 0 .3.7 1 1 0 0 0 .7.3h1a1 1 0 0 0 .7-.3 1 1 0 0 0 .3-.7 1 1 0 0 0-.3-.7Zm-15 1.4A1 1 0 0 0 6 12a1 1 0 0 0-.3-.7A1 1 0 0 0 5 11H4a1 1 0 0 0-.7.3 1 1 0 0 0-.3.7 1 1 0 0 0 .3.7 1 1 0 0 0 .7.3h1a1 1 0 0 0 .7-.3Zm13 4.6-.7-.8a1 1 0 0 0-.8-.3 1 1 0 0 0-.7.3 1 1 0 0 0-.3.7 1 1 0 0 0 .3.8l.7.7a1 1 0 0 0 .7.3 1 1 0 0 0 .8-.3 1 1 0 0 0 .3-.8 1 1 0 0 0-.3-.7ZM5.3 6.7l.7.8a1 1 0 0 0 .8.3 1 1 0 0 0 .7-.3 1 1 0 0 0 .3-.7 1 1 0 0 0-.3-.8l-.7-.7A1 1 0 0 0 6 5a1 1 0 0 0-.8.3 1 1 0 0 0-.3.8 1 1 0 0 0 .3.6Zm12 1a1 1 0 0 0 .7-.2l.7-.7A1 1 0 0 0 19 6a1 1 0 0 0-.3-.8 1 1 0 0 0-.8-.3 1 1 0 0 0-.7.3l-.7.7a1 1 0 0 0-.3.8 1 1 0 0 0 .3.7 1 1 0 0 0 .7.2ZM6.7 16.4a1 1 0 0 0-.8.2l-.7.7a1 1 0 0 0-.3.7 1 1 0 0 0 .3.8 1 1 0 0 0 .8.3 1 1 0 0 0 .7-.3l.7-.7a1 1 0 0 0 .3-.8 1 1 0 0 0-.3-.7 1 1 0 0 0-.7-.3Z"/><path fill-rule="evenodd" d="M13.6 8.3A4 4 0 0 0 12 8a4 4 0 0 0-1.6.3A4 4 0 0 0 9 9.5a4 4 0 0 0-.6 1A4 4 0 0 0 8 12c0 .6.1 1 .3 1.6a4 4 0 0 0 2.1 2 4 4 0 0 0 1.6.4 4 4 0 0 0 1.6-.3 4 4 0 0 0 2-2.1A4 4 0 0 0 16 12a4 4 0 0 0-.3-1.6 4 4 0 0 0-2.1-2ZM14 12a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/></svg>',
|
||||
'browse': '<svg width="24" height="24"><path d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4v-2h4V8H5v10h4v2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm-8 9.4-2.3 2.3a1 1 0 1 1-1.4-1.4l4-4a1 1 0 0 1 1.4 0l4 4a1 1 0 0 1-1.4 1.4L13 13.4V20a1 1 0 0 1-2 0v-6.6Z" fill-rule="nonzero"/></svg>',
|
||||
'camera': '<svg height="24" width="24"><g clip-rule="evenodd" fill-rule="evenodd"><path d="M10 5.4a1 1 0 0 1 .8-.4h4.4a1 1 0 0 1 .8.4l1.2 1.8h2.3A2.5 2.5 0 0 1 22 9.6v8a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 4 17.6v-8a2.5 2.5 0 0 1 2.5-2.4h2.3zM11.4 7l-1.2 1.7a1 1 0 0 1-.8.5h-3a.5.5 0 0 0-.4.4v8a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.4h-2.9a1 1 0 0 1-.8-.5L14.6 7z"/><path d="M13 11.4a2 2 0 1 0 0 3.8 2 2 0 0 0 0-3.8zm-4 1.9a4 4 0 1 1 8 0 4 4 0 0 1-8 0z"/></g></svg>',
|
||||
'cancel': '<svg width="24" height="24"><path d="M12 4.6a7.4 7.4 0 1 1 0 14.8 7.4 7.4 0 0 1 0-14.8ZM12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 8L14.8 8l1 1.1-2.7 2.8 2.7 2.7-1.1 1.1-2.7-2.7-2.7 2.7-1-1.1 2.6-2.7-2.7-2.7 1-1.1 2.8 2.7Z" fill-rule="nonzero"/></svg>',
|
||||
'caption': '<svg height="24" width="24"><path d="M6 16h8v-2H6zm10 0h2v-2h-2zM4 20a2 2 0 0 1-1.5-.6A2 2 0 0 1 2 18V6c0-.6.2-1 .6-1.4A2 2 0 0 1 4 4h16c.6 0 1 .2 1.4.6.4.4.6.9.6 1.4v12c0 .6-.2 1-.6 1.4a2 2 0 0 1-1.4.6zm0-2h16V6H4z"/></svg>',
|
||||
'cell-background-color': '<svg width="24" height="24"><path d="m15.7 2 1.6 1.6-2.7 2.6 5.9 5.8c.7.7.7 1.7 0 2.4l-6.3 6.1a1.7 1.7 0 0 1-2.4 0l-6.3-6.1c-.7-.7-.7-1.7 0-2.4L15.7 2ZM18 12l-4.5-4L9 12h9ZM4 16s2 2.4 2 3.8C6 21 5.1 22 4 22s-2-1-2-2.2C2 18.4 4 16 4 16Z"/></svg>',
|
||||
'cell-border-color': '<svg width="24" height="24"><g fill-rule="evenodd"><path fill-rule="nonzero" d="M5 13v5h2v2H5a2 2 0 0 1-2-2v-5h2zm8-7V4h6a2 2 0 0 1 2 2h-8z" opacity=".2"/><path fill-rule="nonzero" d="M13 4v2H5v7H3V6c0-1.1.9-2 2-2h8zm-2.6 14.1.1-.1.1.1.2.3.2.2.2.2c.4.6.8 1.2.8 1.7 0 .8-.7 1.5-1.5 1.5S9 21.3 9 20.5c0-.5.4-1.1.8-1.7l.2-.2.2-.2.2-.3z"/><path d="m13 11-2 2H5v-2h6V6h2z"/><path fill-rule="nonzero" d="m18.4 8 1 1-1.8 1.9 4 4c.5.4.5 1.1 0 1.6l-4.3 4.2a1.2 1.2 0 0 1-1.6 0l-4.4-4.2c-.4-.5-.4-1.2 0-1.7l7-6.8Zm1.6 7-3-3-3 3h6Z"/></g></svg>',
|
||||
'change-case': '<svg width="24" height="24"><path d="M18.4 18.2v-.6c-.5.8-1.3 1.2-2.4 1.2-2.2 0-3.3-1.6-3.3-4.8 0-3.1 1-4.7 3.3-4.7 1.1 0 1.8.3 2.4 1.1v-.6c0-.5.4-.8.8-.8s.8.3.8.8v8.4c0 .5-.4.8-.8.8a.8.8 0 0 1-.8-.8zm-2-7.4c-1.3 0-1.8.9-1.8 3.2 0 2.4.5 3.3 1.7 3.3 1.3 0 1.8-.9 1.8-3.2 0-2.4-.5-3.3-1.7-3.3zM10 15.7H5.5l-.8 2.6a1 1 0 0 1-1 .7h-.2a.7.7 0 0 1-.7-1l4-12a1 1 0 0 1 2 0l4 12a.7.7 0 0 1-.8 1h-.2a1 1 0 0 1-1-.7l-.8-2.6zm-.3-1.5-2-6.5-1.9 6.5h3.9z" fill-rule="evenodd"/></svg>',
|
||||
'change-tone': '<svg height="24" width="24"><path d="M13 20h-2V8h2zm-4-3H7v-6h2zm8 0h-2v-6h2zM5 15.5H3v-3h2zm16 0h-2v-3h2zM17.7 4.2a.3.3 0 0 1 .6 0 4.1 4.1 0 0 0 2.5 2.5.3.3 0 0 1 0 .6l-.2.1a4.1 4.1 0 0 0-2.3 2.4c-.1.3-.5.3-.6 0a4.1 4.1 0 0 0-2.3-2.4h-.2a.3.3 0 0 1 0-.7 4.1 4.1 0 0 0 2.5-2.5z"/></svg>',
|
||||
'character-count': '<svg width="24" height="24"><path d="M4 11.5h16v1H4v-1Zm4.8-6.8V10H7.7V5.8h-1v-1h2ZM11 8.3V9h2v1h-3V7.7l2-1v-.9h-2v-1h3v2.4l-2 1Zm6.3-3.4V10h-3.1V9h2.1V8h-2.1V6.8h2.1v-1h-2.1v-1h3.1ZM5.8 16.4c0-.5.2-.8.5-1 .2-.2.6-.3 1.2-.3l.8.1c.2 0 .4.2.5.3l.4.4v2.8l.2.3H8.2V18.7l-.6.3H7c-.4 0-.7 0-1-.2a1 1 0 0 1-.3-.9c0-.3 0-.6.3-.8.3-.2.7-.4 1.2-.4l.6-.2h.3v-.2l-.1-.2a.8.8 0 0 0-.5-.1 1 1 0 0 0-.4 0l-.3.4h-1Zm2.3.8h-.2l-.2.1-.4.1a1 1 0 0 0-.4.2l-.2.2.1.3.5.1h.4l.4-.4v-.6Zm2-3.4h1.2v1.7l.5-.3h.5c.5 0 .9.1 1.2.5.3.4.5.8.5 1.4 0 .6-.2 1.1-.5 1.5-.3.4-.7.6-1.3.6l-.6-.1-.4-.4v.4h-1.1v-5.4Zm1.1 3.3c0 .3 0 .6.2.8a.7.7 0 0 0 1.2 0l.2-.8c0-.4 0-.6-.2-.8a.7.7 0 0 0-.6-.3l-.6.3-.2.8Zm6.1-.5c0-.2 0-.3-.2-.4a.8.8 0 0 0-.5-.2c-.3 0-.5.1-.6.3l-.2.9c0 .3 0 .6.2.8.1.2.3.3.6.3.2 0 .4 0 .5-.2l.2-.4h1.1c0 .5-.3.8-.6 1.1a2 2 0 0 1-1.3.4c-.5 0-1-.2-1.3-.6a2 2 0 0 1-.5-1.4c0-.6.1-1.1.5-1.5.3-.4.8-.5 1.4-.5.5 0 1 0 1.2.3.4.3.5.7.5 1.2h-1v-.1Z" fill-rule="evenodd"/></svg>',
|
||||
'chat-commands': '<svg height="24" width="24"><path d="M14.3 3.3h.4l-2 2H9.3a5.5 5.5 0 0 0 0 11h3.4v2.1l4.4-3h.1a5.5 5.5 0 0 0 2.5-4.3v-.8l1.7-1.7a7.5 7.5 0 0 1-3.1 8.5l-7.6 5.1v-4H9.3a7.5 7.5 0 0 1 0-15z"/><path d="M12.4 7.3a1 1 0 0 1 1.5 1.4l-.8.8a1 1 0 0 1 .8 1.7l-2.3 2.5a1 1 0 0 1-1.5-1.4l.8-.8a1 1 0 0 1-.8-1.7zm6.3-5a.3.3 0 0 1 .6 0 4.1 4.1 0 0 0 2.5 2.4.3.3 0 0 1 0 .6l-.2.1a4.1 4.1 0 0 0-2.3 2.4c-.1.3-.5.3-.6 0a4.1 4.1 0 0 0-2.3-2.4h-.2a.3.3 0 0 1 0-.7 4.1 4.1 0 0 0 2.5-2.5z"/></svg>',
|
||||
'chat-reasoning': '<svg width="16" height="16"><path d="M9.1 12.7V14H7v-1.3H9Zm.6-2.2V12H6.3v-1.4h3.4Zm1-4.6A2.5 2.5 0 0 0 9.9 4 2.7 2.7 0 0 0 8 3.3c-.7 0-1.4.3-1.9.8a2.5 2.5 0 0 0-.8 1.8c0 .4.1.8.7 1.4.4.4.8.9 1 1.6a.7.7 0 1 1-1.3.3 2 2 0 0 0-.7-1A3 3 0 0 1 4 6c0-1 .4-2 1.2-2.8A4 4 0 0 1 8 2c1 0 2 .4 2.8 1.1A3.8 3.8 0 0 1 12 6a3.1 3.1 0 0 1-1 2.3l-.7 1A.7.7 0 1 1 9 8.9c.2-.7.6-1.2 1-1.6a1.8 1.8 0 0 0 .7-1.4Z"/></svg>',
|
||||
'chat-send': '<svg width="24" height="24"><path d="M11 19a1 1 0 1 0 2 0V7.8l5.3 5.3a1 1 0 1 0 1.4-1.4L12 4l-7.7 7.7a1 1 0 1 0 1.4 1.4L11 7.8V19Z"/></svg>',
|
||||
'chat-web-search': '<svg height="16" width="16"><path d="M8 2.5a6 6 0 1 1 0 12 6 6 0 0 1 0-12zM3.4 9.2a4.7 4.7 0 0 0 3.1 3.7v-.2a8.8 8.8 0 0 1-1.1-3.5zm7.2 0a8.8 8.8 0 0 1-1 3.5l-.1.2a4.7 4.7 0 0 0 3.1-3.7zm-3.9 0A7.5 7.5 0 0 0 8 12.8a7.5 7.5 0 0 0 1.3-3.6zM6.5 4a4.7 4.7 0 0 0-3.1 3.7h2a8.8 8.8 0 0 1 1-3.5l.1-.2zm1.5.1a7.5 7.5 0 0 0-1.3 3.6h2.6A7.5 7.5 0 0 0 8 4.2zm1.6 0a8.8 8.8 0 0 1 1 3.6h2a4.7 4.7 0 0 0-3.1-3.7v.2z" fill-rule="evenodd"/></svg>',
|
||||
'checklist-rtl': '<svg width="24" height="24"><path d="M5 17h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2zm0-6h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2zm0-6h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2zm14.2 11c.2-.4.6-.5.9-.3.3.2.4.6.2 1L18 20c-.2.3-.7.4-1 0l-1.3-1.3a.7.7 0 0 1 0-1c.3-.2.7-.2 1 0l.7.9 1.7-2.8zm0-6c.2-.4.6-.5.9-.3.3.2.4.6.2 1L18 14c-.2.3-.7.4-1 0l-1.3-1.3a.7.7 0 0 1 0-1c.3-.2.7-.2 1 0l.7.9 1.7-2.8zm0-6c.2-.4.6-.5.9-.3.3.2.4.6.2 1L18 8c-.2.3-.7.4-1 0l-1.3-1.3a.7.7 0 0 1 0-1c.3-.2.7-.2 1 0l.7.9 1.7-2.8z" fill-rule="evenodd"/></svg>',
|
||||
'checklist': '<svg width="24" height="24"><path d="M11 17h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0-6h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0-6h8a1 1 0 0 1 0 2h-8a1 1 0 0 1 0-2ZM7.2 16c.2-.4.6-.5.9-.3.3.2.4.6.2 1L6 20c-.2.3-.7.4-1 0l-1.3-1.3a.7.7 0 0 1 0-1c.3-.2.7-.2 1 0l.7.9 1.7-2.8Zm0-6c.2-.4.6-.5.9-.3.3.2.4.6.2 1L6 14c-.2.3-.7.4-1 0l-1.3-1.3a.7.7 0 0 1 0-1c.3-.2.7-.2 1 0l.7.9 1.7-2.8Zm0-6c.2-.4.6-.5.9-.3.3.2.4.6.2 1L6 8c-.2.3-.7.4-1 0L3.8 6.9a.7.7 0 0 1 0-1c.3-.2.7-.2 1 0l.7.9 1.7-2.8Z" fill-rule="evenodd"/></svg>',
|
||||
'checkmark-filled': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm4.8 5a1 1 0 0 0-1.4.3l-5.4 8-2-2.1a1 1 0 1 0-1.5 1.4l3 3a1 1 0 0 0 1.5-.2l6-9a1 1 0 0 0-.2-1.4Z"/></svg>',
|
||||
'checkmark': '<svg width="24" height="24"><path d="M18.2 5.4a1 1 0 0 1 1.6 1.2l-8 12a1 1 0 0 1-1.5.1l-5-5a1 1 0 1 1 1.4-1.4l4.1 4.1 7.4-11Z" fill-rule="nonzero"/></svg>',
|
||||
'chevron-down': '<svg width="10" height="10"><path d="M8.7 2.2c.3-.3.8-.3 1 0 .4.4.4.9 0 1.2L5.7 7.8c-.3.3-.9.3-1.2 0L.2 3.4a.8.8 0 0 1 0-1.2c.3-.3.8-.3 1.1 0L5 6l3.7-3.8Z" fill-rule="nonzero"/></svg>',
|
||||
'chevron-left': '<svg width="10" height="10"><path d="M7.8 1.3 4 5l3.8 3.7c.3.3.3.8 0 1-.4.4-.9.4-1.2 0L2.2 5.7a.8.8 0 0 1 0-1.2L6.6.2C7 0 7.4 0 7.8.2c.3.3.3.8 0 1.1Z" fill-rule="nonzero"/></svg>',
|
||||
'chevron-right': '<svg width="10" height="10"><path d="M2.2 1.3a.8.8 0 0 1 0-1c.4-.4.9-.4 1.2 0l4.4 4.1c.3.4.3.9 0 1.2L3.4 9.8c-.3.3-.8.3-1.2 0a.8.8 0 0 1 0-1.1L6 5 2.2 1.3Z" fill-rule="nonzero"/></svg>',
|
||||
'chevron-up': '<svg width="10" height="10"><path d="M8.7 7.8 5 4 1.3 7.8c-.3.3-.8.3-1 0a.8.8 0 0 1 0-1.2l4.1-4.4c.3-.3.9-.3 1.2 0l4.2 4.4c.3.3.3.9 0 1.2-.3.3-.8.3-1.1 0Z" fill-rule="nonzero"/></svg>',
|
||||
'close-filled': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm4.7 5.3a1 1 0 0 0-1.3 0L12 10.6 8.6 7.3a1 1 0 0 0-1.3 1.3l3.3 3.4-3.3 3.4a1 1 0 0 0 1.3 1.3l3.4-3.3 3.4 3.3a1 1 0 0 0 1.3-1.3L13.4 12l3.3-3.4a1 1 0 0 0 0-1.3Z"/></svg>',
|
||||
'close': '<svg width="24" height="24"><path d="M17.3 8.2 13.4 12l3.9 3.8a1 1 0 0 1-1.5 1.5L12 13.4l-3.8 3.9a1 1 0 0 1-1.5-1.5l3.9-3.8-3.9-3.8a1 1 0 0 1 1.5-1.5l3.8 3.9 3.8-3.9a1 1 0 0 1 1.5 1.5Z" fill-rule="evenodd"/></svg>',
|
||||
'code-sample': '<svg width="24" height="26"><path d="M7.1 11a2.8 2.8 0 0 1-.8 2 2.8 2.8 0 0 1 .8 2v1.7c0 .3.1.6.4.8.2.3.5.4.8.4.3 0 .4.2.4.4v.8c0 .2-.1.4-.4.4-.7 0-1.4-.3-2-.8-.5-.6-.8-1.3-.8-2V15c0-.3-.1-.6-.4-.8-.2-.3-.5-.4-.8-.4a.4.4 0 0 1-.4-.4v-.8c0-.2.2-.4.4-.4.3 0 .6-.1.8-.4.3-.2.4-.5.4-.8V9.3c0-.7.3-1.4.8-2 .6-.5 1.3-.8 2-.8.3 0 .4.2.4.4v.8c0 .2-.1.4-.4.4-.3 0-.6.1-.8.4-.3.2-.4.5-.4.8V11Zm9.8 0V9.3c0-.3-.1-.6-.4-.8-.2-.3-.5-.4-.8-.4a.4.4 0 0 1-.4-.4V7c0-.2.1-.4.4-.4.7 0 1.4.3 2 .8.5.6.8 1.3.8 2V11c0 .3.1.6.4.8.2.3.5.4.8.4.2 0 .4.2.4.4v.8c0 .2-.2.4-.4.4-.3 0-.6.1-.8.4-.3.2-.4.5-.4.8v1.7c0 .7-.3 1.4-.8 2-.6.5-1.3.8-2 .8a.4.4 0 0 1-.4-.4v-.8c0-.2.1-.4.4-.4.3 0 .6-.1.8-.4.3-.2.4-.5.4-.8V15a2.8 2.8 0 0 1 .8-2 2.8 2.8 0 0 1-.8-2Zm-3.3-.4c0 .4-.1.8-.5 1.1-.3.3-.7.5-1.1.5-.4 0-.8-.2-1.1-.5-.4-.3-.5-.7-.5-1.1 0-.5.1-.9.5-1.2.3-.3.7-.4 1.1-.4.4 0 .8.1 1.1.4.4.3.5.7.5 1.2ZM12 13c.4 0 .8.1 1.1.5.4.3.5.7.5 1.1 0 1-.1 1.6-.5 2a3 3 0 0 1-1.1 1c-.4.3-.8.4-1.1.4a.5.5 0 0 1-.5-.5V17a3 3 0 0 0 1-.2l.6-.6c-.6 0-1-.2-1.3-.5-.2-.3-.3-.7-.3-1 0-.5.1-1 .5-1.2.3-.4.7-.5 1.1-.5Z" fill-rule="evenodd"/></svg>',
|
||||
'collapse': '<svg width="24" height="24"><path d="M4 3a1 1 0 0 1 1 1v16a1 1 0 0 1-2 0V4a1 1 0 0 1 1-1Zm11 4.6a1 1 0 0 1 1.4 0L21 12l-4.6 4.6a1 1 0 0 1-1.4-1.4l2.2-2.2H10a1 1 0 1 1 0-2h7.2L15 9a1 1 0 0 1 0-1.4Z"/></svg>',
|
||||
'color-levels': '<svg width="24" height="24"><path d="M17.5 11.4A9 9 0 0 1 18 14c0 .5 0 1-.2 1.4 0 .4-.3.9-.5 1.3a6.2 6.2 0 0 1-3.7 3 5.7 5.7 0 0 1-3.2 0A5.9 5.9 0 0 1 7.6 18a6.2 6.2 0 0 1-1.4-2.6 6.7 6.7 0 0 1 0-2.8c0-.4.1-.9.3-1.3a13.6 13.6 0 0 1 2.3-4A20 20 0 0 1 12 4a26.4 26.4 0 0 1 3.2 3.4 18.2 18.2 0 0 1 2.3 4Zm-2 4.5c.4-.7.5-1.4.5-2a7.3 7.3 0 0 0-1-3.2c.2.6.2 1.2.2 1.9a4.5 4.5 0 0 1-1.3 3 5.3 5.3 0 0 1-2.3 1.5 4.9 4.9 0 0 1-2 .1 4.3 4.3 0 0 0 2.4.8 4 4 0 0 0 2-.6 4 4 0 0 0 1.5-1.5Z" fill-rule="evenodd"/></svg>',
|
||||
'color-picker': '<svg width="24" height="24"><path d="M12 3a9 9 0 0 0 0 18 1.5 1.5 0 0 0 1.1-2.5c-.2-.3-.4-.6-.4-1 0-.8.7-1.5 1.5-1.5H16a5 5 0 0 0 5-5c0-4.4-4-8-9-8Zm-5.5 9a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Zm3-4a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Zm3 4a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Z" fill-rule="nonzero"/></svg>',
|
||||
'color-swatch-remove-color': '<svg width="24" height="24" class="color-swatch-remove-color"><path stroke="#000" stroke-width="2" d="M21 3 3 21" fill-rule="evenodd"/></svg>',
|
||||
'color-swatch': '<svg width="24" height="24"><rect x="3" y="3" width="18" height="18" rx="1" fill-rule="evenodd"/></svg>',
|
||||
'comment-add': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="m9 19 3-2h7c.6 0 1-.4 1-1V6c0-.6-.4-1-1-1H5a1 1 0 0 0-1 1v10c0 .6.4 1 1 1h4v2Zm-2 4v-4H5a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3h-6.4L7 23Z"/><path d="M13 10h2a1 1 0 0 1 0 2h-2v2a1 1 0 0 1-2 0v-2H9a1 1 0 0 1 0-2h2V8a1 1 0 0 1 2 0v2Z"/></g></svg>',
|
||||
'comment': '<svg width="24" height="24"><path fill-rule="nonzero" d="m9 19 3-2h7c.6 0 1-.4 1-1V6c0-.6-.4-1-1-1H5a1 1 0 0 0-1 1v10c0 .6.4 1 1 1h4v2Zm-2 4v-4H5a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3h-6.4L7 23Z"/></svg>',
|
||||
'continue-writing': '<svg height="24" width="24"><path d="M19.6 14.1a1.5 1.5 0 0 1 1 .5l1 .9.3.5.1.5-.1.6a1.3 1.3 0 0 1-.3.5L16 23H13v-3l5.5-5.5.5-.3h.6zm-5.1 6.5v1h1l4-4-1-1z" fill-rule="evenodd"/><path d="M10 12.9H2v-2h8zm4-4H2V7h12zm0-4H2V3h12zm4.2-1.7c0-.3.5-.3.7 0a4.4 4.4 0 0 0 2.6 2.6.4.4 0 0 1 0 .7l-.2.1a4.4 4.4 0 0 0-2.4 2.5.4.4 0 0 1-.7 0 4.4 4.4 0 0 0-2.5-2.5h-.2a.4.4 0 0 1 0-.8 4.4 4.4 0 0 0 2.6-2.6z"/></svg>',
|
||||
'contrast': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 4a7.8 7.8 0 0 1 5.7 2.3A8 8 0 0 1 19.4 9c.4 1 .6 2 .6 3.1s-.2 2.1-.6 3.1a8 8 0 0 1-1.7 2.6 8 8 0 0 1-2.6 1.7 7.8 7.8 0 0 1-3.1.6 7.8 7.8 0 0 1-3.1-.6 8 8 0 0 1-2.6-1.7A8 8 0 0 1 4.6 15 7.8 7.8 0 0 1 4 12c0-1.1.2-2.1.6-3.1a8 8 0 0 1 1.7-2.6A8 8 0 0 1 9 4.6 7.8 7.8 0 0 1 12 4Zm-6 8a6 6 0 0 0 1.8 4.2 6 6 0 0 0 1.9 1.3 5.8 5.8 0 0 0 2.3.5V6c-.8 0-1.6.2-2.3.5a6 6 0 0 0-2 1.3 6 6 0 0 0-1.2 1.9A5.8 5.8 0 0 0 6 12Z"/></svg>',
|
||||
'copy': '<svg width="24" height="24"><path d="M16 3H6a2 2 0 0 0-2 2v11h2V5h10V3Zm1 4a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2V9c0-1.2.9-2 2-2h7Zm0 12V9h-7v10h7Z" fill-rule="nonzero"/></svg>',
|
||||
'crop': '<svg width="24" height="24"><path d="M17 8v7h2c.6 0 1 .4 1 1s-.4 1-1 1h-2v2c0 .6-.4 1-1 1a1 1 0 0 1-1-1v-2H7V9H5a1 1 0 1 1 0-2h2V5c0-.6.4-1 1-1s1 .4 1 1v2h7l3-3 1 1-3 3ZM9 9v5l5-5H9Zm1 6h5v-5l-5 5Z" fill-rule="evenodd"/></svg>',
|
||||
'cut-column': '<svg width="24" height="24"><path fill-rule="evenodd" d="M7.2 4.5c.9 0 1.6.4 2.2 1A3.7 3.7 0 0 1 10.5 8v.5l1 1 4-4 1-.5a3.3 3.3 0 0 1 2 0c.4 0 .7.3 1 .5L17 8h4v13h-6V10l-1.5 1.5.5.5v4l-2.5-2.5-1 1v.5c0 .4 0 .8-.3 1.2-.2.5-.4.9-.8 1.2-.6.7-1.3 1-2.2 1-.8.2-1.5 0-2-.6l-.5-.8-.2-1c0-.4 0-.8.3-1.2A3.9 3.9 0 0 1 7 12.7c.5-.2 1-.3 1.5-.2l1-1-1-1c-.5 0-1 0-1.5-.2-.5-.1-1-.4-1.4-.9-.4-.3-.6-.7-.8-1.2L4.5 7c0-.4 0-.7.2-1 0-.3.3-.6.5-.8.5-.5 1.2-.8 2-.7Zm12.3 5h-3v10h3v-10ZM8 13.8h-.3l-.4.2a2.8 2.8 0 0 0-.7.4v.1a2.8 2.8 0 0 0-.6.8l-.1.4v.7l.2.5.5.2h.7a2.6 2.6 0 0 0 .8-.3 2.4 2.4 0 0 0 .7-.7 2.5 2.5 0 0 0 .3-.8 1.5 1.5 0 0 0 0-.8 1 1 0 0 0-.2-.4 1 1 0 0 0-.5-.2H8Zm3.5-3.7c-.4 0-.7.1-1 .4-.3.3-.4.6-.4 1s.1.7.4 1c.3.3.6.4 1 .4s.7-.1 1-.4c.3-.3.4-.6.4-1s-.1-.7-.4-1c-.3-.3-.6-.4-1-.4ZM7 5.8h-.4a1 1 0 0 0-.5.3 1 1 0 0 0-.2.5v.7a2.5 2.5 0 0 0 .3.8l.2.3h.1l.4.4.4.2.4.1h.7L9 9l.2-.4a1.6 1.6 0 0 0 0-.8 2.6 2.6 0 0 0-.3-.8A2.5 2.5 0 0 0 7.7 6l-.4-.1H7Z"/></svg>',
|
||||
'cut-row': '<svg width="24" height="24"><path fill-rule="evenodd" d="M22 3v5H9l3 3 2-2h4l-4 4 1 1h.5c.4 0 .8 0 1.2.3.5.2.9.4 1.2.8.7.6 1 1.3 1 2.2.2.8 0 1.5-.6 2l-.8.5-1 .2c-.4 0-.8 0-1.2-.3a3.9 3.9 0 0 1-2.1-2.2c-.2-.5-.3-1-.2-1.5l-1-1-1 1c0 .5 0 1-.2 1.5-.1.5-.4 1-.9 1.4-.3.4-.7.6-1.2.8l-1.2.3c-.4 0-.7 0-1-.2-.3 0-.6-.3-.8-.5-.5-.5-.8-1.2-.7-2 0-.9.4-1.6 1-2.2A3.7 3.7 0 0 1 8.6 14H9l1-1-4-4-.5-1a3.3 3.3 0 0 1 0-2c0-.4.3-.7.5-1l2 2V3h14ZM8.5 15.3h-.3a2.6 2.6 0 0 0-.8.4 2.5 2.5 0 0 0-.9 1.1l-.1.4v.7l.2.5.5.2h.7a2.5 2.5 0 0 0 .8-.3L9 18V18l.4-.4.2-.4.1-.4v-.7a1 1 0 0 0-.2-.5 1 1 0 0 0-.4-.2h-.5Zm7 0H15a1 1 0 0 0-.4.3 1 1 0 0 0-.2.5 1.5 1.5 0 0 0 0 .7v.4a2.8 2.8 0 0 0 .5.7h.1a2.8 2.8 0 0 0 .8.6l.4.1h.7l.5-.2.2-.5v-.7a2.6 2.6 0 0 0-.3-.8 2.4 2.4 0 0 0-.7-.7 2.5 2.5 0 0 0-.8-.3h-.3ZM12 11.6c-.4 0-.7.1-1 .4-.3.3-.4.6-.4 1s.1.7.4 1c.3.3.6.4 1 .4s.7-.1 1-.4c.3-.3.4-.6.4-1s-.1-.7-.4-1c-.3-.3-.6-.4-1-.4Zm8.5-7.1h-11v2h11v-2Z"/></svg>',
|
||||
'cut': '<svg width="24" height="24"><path d="M18 15c.6.7 1 1.4 1 2.3 0 .8-.2 1.5-.7 2l-.8.5-1 .2c-.4 0-.8 0-1.2-.3a3.9 3.9 0 0 1-2.1-2.2c-.2-.5-.3-1-.2-1.5l-1-1-1 1c0 .5 0 1-.2 1.5-.1.5-.4 1-.9 1.4-.3.4-.7.6-1.2.8l-1.2.3c-.4 0-.7 0-1-.2-.3 0-.6-.3-.8-.5-.5-.5-.8-1.2-.7-2 0-.9.4-1.6 1-2.2A3.7 3.7 0 0 1 8.6 14H9l1-1-4-4-.5-1a3.3 3.3 0 0 1 0-2c0-.4.3-.7.5-1l6 6 6-6 .5 1a3.3 3.3 0 0 1 0 2c0 .4-.3.7-.5 1l-4 4 1 1h.5c.4 0 .8 0 1.2.3.5.2.9.4 1.2.8Zm-8.5 2.2.1-.4v-.7a1 1 0 0 0-.2-.5 1 1 0 0 0-.4-.2 1.6 1.6 0 0 0-.8 0 2.6 2.6 0 0 0-.8.3 2.5 2.5 0 0 0-.9 1.1l-.1.4v.7l.2.5.5.2h.7a2.5 2.5 0 0 0 .8-.3 2.8 2.8 0 0 0 1-1Zm2.5-2.8c.4 0 .7-.1 1-.4.3-.3.4-.6.4-1s-.1-.7-.4-1c-.3-.3-.6-.4-1-.4s-.7.1-1 .4c-.3.3-.4.6-.4 1s.1.7.4 1c.3.3.6.4 1 .4Zm5.4 4 .2-.5v-.7a2.6 2.6 0 0 0-.3-.8 2.4 2.4 0 0 0-.7-.7 2.5 2.5 0 0 0-.8-.3 1.5 1.5 0 0 0-.8 0 1 1 0 0 0-.4.2 1 1 0 0 0-.2.5 1.5 1.5 0 0 0 0 .7v.4l.3.4.3.4a2.8 2.8 0 0 0 .8.5l.4.1h.7l.5-.2Z" fill-rule="evenodd"/></svg>',
|
||||
'document-gear-properties': '<svg height="24" width="24"><path d="M17.3 14.5a7.1 7.1 0 0 1 1.2.6l1.5-.5 1 1.8-1.2 1V18.7l1.2 1-1 1.6-1.5-.4a4.5 4.5 0 0 1-.6.3 7.1 7.1 0 0 1-.6.3L17 23h-2l-.3-1.5a7.4 7.4 0 0 1-.6-.3 4.5 4.5 0 0 1-.6-.3l-1.5.5-1-1.8 1.2-1a3.9 3.9 0 0 1 0-.6v-.7l-1.2-1 1-1.6 1.5.4a4.5 4.5 0 0 1 1.2-.6L15 13h2zM16 16a2 2 0 0 0-1.5.6A1.9 1.9 0 0 0 14 18c0 .6.2 1 .6 1.4s1 .6 1.5.6 1-.2 1.5-.6.6-.8.6-1.4-.2-1-.6-1.4A2 2 0 0 0 16 16z" fill-rule="evenodd"/><path d="M19 7.6V12h-2V9h-4V5H7v14h3v2H7a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h7.4z"/></svg>',
|
||||
'document-properties': '<svg width="24" height="24"><path d="M14.4 3H7a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h10a2 2 0 0 0 2-2V7.6L14.4 3ZM17 19H7V5h6v4h4v10Z" fill-rule="nonzero"/></svg>',
|
||||
'drag': '<svg width="24" height="24"><path d="M13 5h2v2h-2V5Zm0 4h2v2h-2V9ZM9 9h2v2H9V9Zm4 4h2v2h-2v-2Zm-4 0h2v2H9v-2Zm0 4h2v2H9v-2Zm4 0h2v2h-2v-2ZM9 5h2v2H9V5Z" fill-rule="evenodd"/></svg>',
|
||||
'dropbox': '<svg height="24" width="24"><path clip-rule="evenodd" d="m6.4 9.3-3-2a1 1 0 0 1 0-1.6l4-2.5a1 1 0 0 1 1 0L12 5.5l3.6-2.3a1 1 0 0 1 1 0l4 2.5a1 1 0 0 1 0 1.7l-3 1.9 3 1.9a1 1 0 0 1 0 1.6l-2.9 1.9V16c0 .3-.1.6-.4.8l-4.5 3a1 1 0 0 1-1.2 0l-4.4-3a1 1 0 0 1-.5-.9v-1l-3.2-2.2a1 1 0 0 1 0-1.6zm3 0L12 7.5l2.6 1.8L12 11zM8.9 15v.4l3.3 2.3 3.4-2.3v-.2L12 13l-3.1 2zm-1-10-2 1.4 2 1.3 2-1.3zm8.2 0-2 1.4 2 1.3 2-1.3zm-2 6.9 2-1.3 2 1.3-2 1.3-2-1.3zm-8.3 0 2-1.3L10 12l-2 1.3-2-1.3z" fill-rule="evenodd"/></svg>',
|
||||
'duplicate-column': '<svg width="24" height="24"><path d="M17 6v16h-7V6h7Zm-2 2h-3v12h3V8Zm-2-6v2H8v15H6V2h7Z"/></svg>',
|
||||
'duplicate-row': '<svg width="24" height="24"><path d="M22 11v7H6v-7h16Zm-2 2H8v3h12v-3Zm-1-6v2H4v5H2V7h17Z"/></svg>',
|
||||
'duplicate': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="M16 3v2H6v11H4V5c0-1.1.9-2 2-2h10Zm3 8h-2V9h-7v10h9a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2V9c0-1.2.9-2 2-2h7a2 2 0 0 1 2 2v2Z"/><path d="M17 14h1a1 1 0 0 1 0 2h-1v1a1 1 0 0 1-2 0v-1h-1a1 1 0 0 1 0-2h1v-1a1 1 0 0 1 2 0v1Z"/></g></svg>',
|
||||
'edit-block': '<svg height="24" width="24"><path d="M14.7 3.2a2 2 0 0 1 2.9 0L20.4 6c.8.8.8 2.1 0 2.9l-10 10a2 2 0 0 1-1.1.6L3 20.6l1-6.2c.2-.5.3-.8.6-1.1zM6.1 14.7 5.5 18l3.4-.6-2.8-2.9zm1.4-1.5 2.9 2.9 6.2-6.3L13.8 7zm7.7-7.6L18 8.4l1-1L16 4.7z" fill-rule="evenodd"/></svg>',
|
||||
'edit-image': '<svg width="24" height="24"><path d="M18 16h2V7a2 2 0 0 0-2-2H7v2h11v9ZM6 17h15a1 1 0 0 1 0 2h-1v1a1 1 0 0 1-2 0v-1H6a2 2 0 0 1-2-2V7H3a1 1 0 1 1 0-2h1V4a1 1 0 1 1 2 0v13Zm3-5.3 1.3 2 3-4.7 3.7 6H7l2-3.3Z" fill-rule="nonzero"/></svg>',
|
||||
'embed-page': '<svg width="24" height="24"><path d="M19 6V5H5v14h2A13 13 0 0 1 19 6Zm0 1.4c-.8.8-1.6 2.4-2.2 4.6H19V7.4Zm0 5.6h-2.4c-.4 1.8-.6 3.8-.6 6h3v-6Zm-4 6c0-2.2.2-4.2.6-6H13c-.7 1.8-1.1 3.8-1.1 6h3Zm-4 0c0-2.2.4-4.2 1-6H9.6A12 12 0 0 0 8 19h3ZM4 3h16c.6 0 1 .4 1 1v16c0 .6-.4 1-1 1H4a1 1 0 0 1-1-1V4c0-.6.4-1 1-1Zm11.8 9c.4-1.9 1-3.4 1.8-4.5a9.2 9.2 0 0 0-4 4.5h2.2Zm-3.4 0a12 12 0 0 1 2.8-4 12 12 0 0 0-5 4h2.2Z" fill-rule="nonzero"/></svg>',
|
||||
'embed': '<svg width="24" height="24"><path d="M4 3h16c.6 0 1 .4 1 1v16c0 .6-.4 1-1 1H4a1 1 0 0 1-1-1V4c0-.6.4-1 1-1Zm1 2v14h14V5H5Zm4.8 2.6 5.6 4a.5.5 0 0 1 0 .8l-5.6 4A.5.5 0 0 1 9 16V8a.5.5 0 0 1 .8-.4Z" fill-rule="nonzero"/></svg>',
|
||||
'emoji': '<svg width="24" height="24"><path d="M9 11c.6 0 1-.4 1-1s-.4-1-1-1a1 1 0 0 0-1 1c0 .6.4 1 1 1Zm6 0c.6 0 1-.4 1-1s-.4-1-1-1a1 1 0 0 0-1 1c0 .6.4 1 1 1Zm-3 5.5c2.1 0 4-1.5 4.4-3.5H7.6c.5 2 2.3 3.5 4.4 3.5ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm0 14.5a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Z" fill-rule="nonzero"/></svg>',
|
||||
'evernote': '<svg width="24" height="24"><path fill-rule="evenodd" d="m9.5 3-.3.1-2.6 2.6C3.8 8.5 4 8.2 4 8.7c0 1.3.3 3 .6 4.3.5 2 1.1 3.2 2 3.7l1.2.3c1 .2 1.5.2 2.2.2 1.3 0 1.7 0 2.2-.4.3-.2.3-.4.4-1a38.4 38.4 0 0 1 .3-.4l.3.1 1.8.1h1.6v-1.8H14l-.1-.2a6.8 6.8 0 0 1-.5-1c-.2-.4-.3-.6-.5-.7a.8.8 0 0 0-.5-.1.9.9 0 0 0-.8.5l-.4 1.6-.3 1.5H9.4l-2-.3a1 1 0 0 1-.1-.2A10.1 10.1 0 0 1 6 9.7v-.2h4l.5-.5V5H12c.4.2.7.7.8 1.1a1 1 0 0 0 .2.6c.2.2.3.2 1 .3 1.2 0 2 .2 2.5.6.4.2.8.7 1 1.2.3 1.2.5 4 .3 6.4 0 1.8-.4 3.4-.8 4h-.7a8 8 0 0 1-.8 0c-.2 0-.3-.3-.2-.5a.5.5 0 0 1 .4-.3h.9v-1.8H16c-.7 0-.9 0-1.2.2-.9.4-1.4 1.2-1.4 2.1l.1.4.1.2a1 1 0 0 1 .1.2c.3.7.8 1.2 1.4 1.3l1.2.1h1.2a2 2 0 0 0 1-.8 9 9 0 0 0 1.1-4.3c.2-3 0-6.3-.4-7.7A4.3 4.3 0 0 0 16 5.3a7 7 0 0 0-1.3-.2 7.2 7.2 0 0 1-.4 0 5.5 5.5 0 0 1 0-.3c-.2-.5-.6-1-1.2-1.4a3 3 0 0 0-.5-.2c-.4-.2-.5-.2-1.8-.2a26.5 26.5 0 0 0-1.3 0Zm-.8 4v.7H7.2l.7-.8.8-.7v.7Zm7 3.5c-.4 0-.7.5-.9 1v.7h.3c.7 0 1.1.2 1.5.6l.3.2.3-.6V11a1 1 0 0 0-.6-.4 2 2 0 0 0-.8 0Z" clip-rule="evenodd"/></svg>',
|
||||
'explain': '<svg height="24" width="24"><path d="M11.6 3.1a4 4 0 0 1 3 6.7 6.8 6.8 0 0 1 1.7 1.2c1 0 2.2 0 3.5.3a1 1 0 0 1 .8.9v8a1 1 0 0 1-1.1 1c-1.5-.3-2.6-.3-3.7-.1-1 .2-2.2.6-3.7 1.4a1 1 0 0 1-.3.1 1 1 0 0 1-.6 0c-1.6-.9-2.7-1.3-3.8-1.5-1-.2-2.2-.2-3.7 0a1 1 0 0 1-1.1-1v-7.9a1 1 0 0 1 .8-1C4.7 11 5.8 11 7 11a6.8 6.8 0 0 1 1.3-1l.4-.2a4 4 0 0 1 3-6.7zm-4 10h-3V19a11.3 11.3 0 0 1 6 1v-5.7c-1.2-.6-2.2-1-3-1.2zm11 0a8.6 8.6 0 0 0-6 1.2V20l2.8-.9c1-.2 2-.2 3.2-.1zm-7-2a4.6 4.6 0 0 0-2.1.5l2.1 1 2.1-1a4.6 4.6 0 0 0-2.1-.5zm0-6a2 2 0 1 0 0 4 2 2 0 0 0 0-4z" fill-rule="evenodd"/><path d="M18.7 2.2a.3.3 0 0 1 .6 0 4.1 4.1 0 0 0 2.5 2.5.3.3 0 0 1 0 .6l-.2.1a4.1 4.1 0 0 0-2.3 2.4c-.1.3-.5.3-.6 0a4.1 4.1 0 0 0-2.3-2.4h-.2a.3.3 0 0 1 0-.7 4.1 4.1 0 0 0 2.5-2.5z"/></svg>',
|
||||
'export-pdf': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M7 3h7.4L19 7.6V17h-2V9h-4V5H7v3H5V5c0-1.1.9-2 2-2Z"/><path d="M2.6 15.2v-1.9h1c.6 0 1-.2 1.4-.5.3-.3.5-.7.5-1.2s-.2-.9-.5-1.2a2 2 0 0 0-1.3-.4H1v5.2h1.6Zm.4-3h-.4v-1.1h.5l.6.1.2.5c0 .1 0 .3-.2.4l-.7.1Zm5.7 3 1-.1c.3 0 .5-.2.7-.4l.5-.8c.2-.3.2-.7.2-1.3v-1l-.5-.8c-.2-.3-.4-.5-.7-.6L8.7 10H6.3v5.2h2.4Zm-.4-1.1H8v-3h.4c.5 0 .8.2 1 .4l.2 1.1-.1 1-.3.3-.8.2Zm5.3 1.2V13h2v-1h-2v-1H16V10h-4v5.2h1.6Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M15 17a1 1 0 1 0-2 0v3.1l-1.4-1a1 1 0 1 0-1.2 1.7l3.6 2.4 3.6-2.4a1 1 0 0 0-1.2-1.6l-1.4 1V17Z"/></svg>',
|
||||
'export-word': '<svg width="24" height="24"><path d="M9.5 7A1.5 1.5 0 0 1 11 8.4v7.1A1.5 1.5 0 0 1 9.6 17H2.5A1.5 1.5 0 0 1 1 15.6V8.5A1.5 1.5 0 0 1 2.4 7h7.1Zm-1 2.8-1 2.6-1-2.5v-.1a.6.6 0 0 0-1 0l-.1.1-.9 2.5-1-2.5v-.1a.6.6 0 0 0-1 .4v.1l1.5 4v.1a.6.6 0 0 0 1 0v-.1l1-2.5.9 2.5v.1a.6.6 0 0 0 1 0H8l1.6-4v-.2a.6.6 0 0 0-1.1-.4Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M7 3h7.4L19 7.6V17h-2V9h-4V5H5c0-1.1.9-2 2-2ZM15 17a1 1 0 1 0-2 0v3.1l-1.4-1a1 1 0 1 0-1.2 1.7l3.6 2.4 3.6-2.4a1 1 0 0 0-1.2-1.6l-1.4 1V17Z"/></svg>',
|
||||
'export': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="M14.4 3 18 7v1h-5V5H7v14h9a1 1 0 0 1 2 0c0 1-.8 2-1.9 2H7c-1 0-2-.8-2-1.9V5c0-1 .8-2 1.9-2h7.5Z"/><path d="M18.1 12c.5 0 .9.4.9 1 0 .5-.3 1-.8 1h-7.3c-.5 0-.9-.4-.9-1 0-.5.3-1 .8-1h7.3Z"/><path d="M16.4 9.2a1 1 0 0 1 1.4.2l2.4 3.6-2.4 3.6a1 1 0 0 1-1.7-1v-.2l1.7-2.4-1.6-2.4a1 1 0 0 1 .2-1.4Z"/></g></svg>',
|
||||
'exposure': '<svg width="24" height="24"><path d="M5 21a2 2 0 0 1-1.4-.6A2 2 0 0 1 3 19V5c0-.6.2-1 .6-1.4A2 2 0 0 1 5 3h14c.6 0 1 .2 1.4.6.4.4.6.8.6 1.4v14c0 .6-.2 1-.6 1.4a2 2 0 0 1-1.4.6H5Zm0-2h14V5L5 19Zm9.5-1v-2h-2v-1.5h2v-2H16v2h2V16h-2v2h-1.5ZM6 8.5h5V7H6v1.5Z"/></svg>',
|
||||
'fb': '<svg width="24" height="24"><path fill-rule="evenodd" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-11 6.7V14H8.1v-2.5h2v-1C10 8.2 11.5 7 12.8 7H16v2.5h-2.4c-.8 0-1 .7-1 1.3v.7h3.2l-1 2.5h-2v5a7 7 0 1 0-2.8-.2Z" clip-rule="evenodd"/></svg>',
|
||||
'feedback': '<svg width="24" height="24"><path fill-rule="evenodd" d="M16 6c1 0 2 1 2 2v5c0 1-1 2-2 2h-3l-4 3v-3H8c-1 0-2-1-2-2V8c0-1 1-2 2-2h8Zm-7 5v1h4v-1H9Zm0-2v1h6V9H9Z"/></svg>',
|
||||
'fill': '<svg width="24" height="26"><path d="m16.6 12-9-9-1.4 1.4 2.4 2.4-5.2 5.1c-.5.6-.5 1.6 0 2.2L9 19.6a1.5 1.5 0 0 0 2.2 0l5.5-5.5c.5-.6.5-1.6 0-2.2ZM5.2 13 10 8.2l4.8 4.8H5.2ZM19 14.5s-2 2.2-2 3.5c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.3-2-3.5-2-3.5Z" fill-rule="nonzero"/></svg>',
|
||||
'fix-grammar': '<svg height="24" width="24"><path d="M22 14.5 14.4 22l-4.6-4.5 1.5-1.5 3 3 6.2-6z"/><path d="M15 15.8h-2.5l-1.2-3.5H5.6l-1.2 3.5H2L7.2 2h2.6zm-8.6-5.5h4.2l-2-5.7h-.2z" fill-rule="evenodd"/><path d="M18.1 2.2c.2-.3.6-.3.8 0a4.4 4.4 0 0 0 2.6 2.6.4.4 0 0 1 0 .7l-.2.1a4.4 4.4 0 0 0-2.4 2.5.4.4 0 0 1-.7 0 4.4 4.4 0 0 0-2.5-2.5h-.2a.4.4 0 0 1 0-.8 4.4 4.4 0 0 0 2.6-2.6z"/></svg>',
|
||||
'flickr': '<svg width="24" height="24"><path d="M8.2 15.3c1.7 0 3.1-1.4 3.1-3.1A3.2 3.2 0 0 0 8.1 9 3.2 3.2 0 0 0 5 12.2c0 1.7 1.4 3 3.2 3Zm7.6 0c1.7 0 3.2-1.4 3.2-3.1a3.2 3.2 0 0 0-6.3 0c0 1.7 1.4 3 3.1 3Z"/></svg>',
|
||||
'flip-horizontally': '<svg width="24" height="24"><path d="M14 19h2v-2h-2v2Zm4-8h2V9h-2v2ZM4 7v10c0 1.1.9 2 2 2h3v-2H6V7h3V5H6a2 2 0 0 0-2 2Zm14-2v2h2a2 2 0 0 0-2-2Zm-7 16h2V3h-2v18Zm7-6h2v-2h-2v2Zm-4-8h2V5h-2v2Zm4 12a2 2 0 0 0 2-2h-2v2Z" fill-rule="nonzero"/></svg>',
|
||||
'flip-vertically': '<svg width="24" height="24"><path d="M5 14v2h2v-2H5Zm8 4v2h2v-2h-2Zm4-14H7a2 2 0 0 0-2 2v3h2V6h10v3h2V6a2 2 0 0 0-2-2Zm2 14h-2v2a2 2 0 0 0 2-2ZM3 11v2h18v-2H3Zm6 7v2h2v-2H9Zm8-4v2h2v-2h-2ZM5 18c0 1.1.9 2 2 2v-2H5Z" fill-rule="nonzero"/></svg>',
|
||||
'folder': '<svg width="24" height="24"><path fill-rule="evenodd" d="M5.6 6a.6.6 0 0 0-.6.6v11.2a.6.6 0 0 0 .6.6h12.8a.6.6 0 0 0 .6-.6V9a.6.6 0 0 0-.6-.6h-7.2a1 1 0 0 1-.8-.4L9 6H5.6ZM3.8 4.8A2.6 2.6 0 0 1 5.6 4h4a1 1 0 0 1 .8.4l1.3 2h6.7A2.6 2.6 0 0 1 21 9v8.8a2.6 2.6 0 0 1-2.6 2.6H5.6A2.6 2.6 0 0 1 3 17.8V6.6c0-.7.3-1.4.8-1.8Z" clip-rule="evenodd"/></svg>',
|
||||
'footnote': '<svg width="24" height="24"><path d="M19 13c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2h14Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M19 4v6h-1V5h-1.5V4h2.6Z"/><path d="M12 18c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2h7ZM14 8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2h9Z"/></svg>',
|
||||
'format-code': '<svg width="24" height="24"><path d="m10 22 2-7H6l9-13h2l-2 8h7L12 22h-2ZM6 2h7l-1.4 2H6V2Zm4.2 4H4v2h4.8l1.4-2Zm-2.7 4H2v2h4l1.5-2Z"/></svg>',
|
||||
'format-painter': '<svg width="24" height="24"><path d="M18 5V4c0-.5-.4-1-1-1H5a1 1 0 0 0-1 1v4c0 .6.5 1 1 1h12c.6 0 1-.4 1-1V7h1v4H9v9c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-7h8V5h-3Z" fill-rule="nonzero"/></svg>',
|
||||
'format': '<svg width="24" height="24"><path fill-rule="evenodd" d="M17 5a1 1 0 0 1 0 2h-4v11a1 1 0 0 1-2 0V7H7a1 1 0 1 1 0-2h10Z"/></svg>',
|
||||
'fullscreen': '<svg width="24" height="24"><path d="m15.3 10-1.2-1.3 2.9-3h-2.3a.9.9 0 1 1 0-1.7H19c.5 0 .9.4.9.9v4.4a.9.9 0 1 1-1.8 0V7l-2.9 3Zm0 4 3 3v-2.3a.9.9 0 1 1 1.7 0V19c0 .5-.4.9-.9.9h-4.4a.9.9 0 1 1 0-1.8H17l-3-2.9 1.3-1.2ZM10 15.4l-2.9 3h2.3a.9.9 0 1 1 0 1.7H5a.9.9 0 0 1-.9-.9v-4.4a.9.9 0 1 1 1.8 0V17l2.9-3 1.2 1.3ZM8.7 10 5.7 7v2.3a.9.9 0 0 1-1.7 0V5c0-.5.4-.9.9-.9h4.4a.9.9 0 0 1 0 1.8H7l3 2.9-1.3 1.2Z" fill-rule="nonzero"/></svg>',
|
||||
'gallery': '<svg width="24" height="24"><path fill-rule="nonzero" d="m5 15.7 2.3-2.2c.3-.3.7-.3 1 0L11 16l5.1-5c.3-.4.8-.4 1 0l2 1.9V8H5v7.7ZM5 18V19h3l1.8-1.9-2-2L5 17.9Zm14-3-2.5-2.4-6.4 6.5H19v-4ZM4 6h16c.6 0 1 .4 1 1v13c0 .6-.4 1-1 1H4a1 1 0 0 1-1-1V7c0-.6.4-1 1-1Zm6 7a2 2 0 1 1 0-4 2 2 0 0 1 0 4ZM4.5 4h15a.5.5 0 1 1 0 1h-15a.5.5 0 0 1 0-1Zm2-2h11a.5.5 0 1 1 0 1h-11a.5.5 0 0 1 0-1Z"/></svg>',
|
||||
'gamma': '<svg height="24" width="24"><path d="M11.5 16.3a.7.7 0 1 0 1.4 0V14l2.7-6a.7.7 0 1 0-1.2-.6l-2.2 5-2.3-4.9C9.6 7 8.7 7 8 7.2v1.1c.8-.2.9 0 1.2 1l2.3 4.7z"/><path d="M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm1 2h14v14H5z" fill-rule="evenodd"/></svg>',
|
||||
'google-drive': '<svg height="24" width="24"><path clip-rule="evenodd" d="M8.7 4.5a1 1 0 0 1 .9-.5h4.8a1 1 0 0 1 .9.5l5.6 9.7a1 1 0 0 1 0 1l-2.4 4.2a1 1 0 0 1-.9.5H6.4a1 1 0 0 1-.9-.5l-2.4-4.2a1 1 0 0 1 0-1zM10.2 6l-4.5 7.7h2.5L12.7 6h-2.5zm4.2 1 4.4 7.7-1.2 2.2-4.4-7.7zM12 11.2l-1.5 2.5h3zm2.6 4.5H5.7L7 17.9h8.9l-1.3-2.2z" fill-rule="evenodd"/></svg>',
|
||||
'google-photos': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12.4 3c-.8 0-1.4.7-1.4 1.4v3.2a5 5 0 0 0-8 4c0 .8.6 1.4 1.4 1.4h3.2a5 5 0 0 0-1 3 5 5 0 0 0 5 5c.8 0 1.4-.6 1.4-1.4v-3.2a5 5 0 0 0 8-4c0-.8-.7-1.4-1.4-1.4h-3.2a5 5 0 0 0-4-8Zm.6 8V5a3 3 0 0 1 2.4 3 3 3 0 0 1-2.4 3Zm-2 0H5a3 3 0 0 1 3-2.4 3 3 0 0 1 3 2.4Zm2 2a3 3 0 0 0 3 2.4 3 3 0 0 0 3-2.4h-6Zm-4.4 3a3 3 0 0 1 2.4-3v6a3 3 0 0 1-2.4-3Z" clip-rule="evenodd"/></svg>',
|
||||
'grayscale': '<svg height="24" width="24"><path d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zm12.4 0H19v3.6zm-2.8 0h-2.2l8.6 8.6v-2.2zm-5 0H5l14 14v-2.6z" fill-rule="evenodd"/></svg>',
|
||||
'help': '<svg width="24" height="24"><g fill-rule="evenodd"><path d="M12 5.5a6.5 6.5 0 0 0-6 9 6.3 6.3 0 0 0 1.4 2l1 1a6.3 6.3 0 0 0 3.6 1 6.5 6.5 0 0 0 6-9 6.3 6.3 0 0 0-1.4-2l-1-1a6.3 6.3 0 0 0-3.6-1ZM12 4a7.8 7.8 0 0 1 5.7 2.3A8 8 0 1 1 12 4Z"/><path d="M9.6 9.7a.7.7 0 0 1-.7-.8c0-1.1 1.5-1.8 3.2-1.8 1.8 0 3.2.8 3.2 2.4 0 1.4-.4 2.1-1.5 2.8-.2 0-.3.1-.3.2a2 2 0 0 0-.8.8.8.8 0 0 1-1.4-.6c.3-.7.8-1 1.3-1.5l.4-.2c.7-.4.8-.6.8-1.5 0-.5-.6-.9-1.7-.9-.5 0-1 .1-1.4.3-.2 0-.3.1-.3.2v-.2c0 .4-.4.8-.8.8Z" fill-rule="nonzero"/><circle cx="12" cy="16" r="1"/></g></svg>',
|
||||
'highlight-bg-color': '<svg width="24" height="24"><g fill-rule="evenodd"><path class="tox-icon-highlight-bg-color__color" d="M3 18h18v3H3z"/><path fill-rule="nonzero" d="M7.7 16.7H3l3.3-3.3-.7-.8L10.2 8l4 4.1-4 4.2c-.2.2-.6.2-.8 0l-.6-.7-1.1 1.1zm5-7.5L11 7.4l3-2.9a2 2 0 0 1 2.6 0L18 6c.7.7.7 2 0 2.7l-2.9 2.9-1.8-1.8-.5-.6"/></g></svg>',
|
||||
'highlight-key-points': '<svg height="24" width="24"><path d="M12 2c1.3 0 2.6.3 3.7.7l-.2.1a2.3 2.3 0 0 0-1.4 1.5 8 8 0 1 0 5.6 5.6 2.3 2.3 0 0 0 1.3-1v-.2h.1v-.2h.1v-.2A10 10 0 0 1 22 12 10 10 0 1 1 12 2z"/><path d="M12 11a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1z"/><path d="M12 6.5a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11zm0 2a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7z" fill-rule="evenodd"/><path d="M18.7 2.2a.3.3 0 0 1 .6 0 4.1 4.1 0 0 0 2.5 2.5.3.3 0 0 1 0 .6l-.2.1a4.1 4.1 0 0 0-2.3 2.4c-.1.3-.5.3-.6 0a4.1 4.1 0 0 0-2.3-2.4h-.2A.3.3 0 0 1 16 5a.4.4 0 0 1 0-.2.3.3 0 0 1 .2 0v-.1h.1a5.2 5.2 0 0 0 .2-.2h.1a4.2 4.2 0 0 0 2-2.3z"/></svg>',
|
||||
'home': '<svg width="24" height="24"><path fill-rule="nonzero" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>',
|
||||
'horizontal-rule': '<svg width="24" height="24"><path d="M4 11h16v2H4z" fill-rule="evenodd"/></svg>',
|
||||
'huddle': '<svg height="24" width="24"><path d="M10.3 5.6V8h.8v-2l3.5 2 4.3-2.5L14.6 3l-4.3 2.6zM14.6 8v5.2l4.3-2.6V5.6L14.6 8zM6 13.3v2.5h.9v-2l3.4 2 4.3-2.5-4.3-2.6zm4.3 2.6V21l4.3-2.6v-5.1l-4.3 2.6z"/></svg>',
|
||||
'image-decorative': '<svg width="24" height="24"><path d="M12 10c.3 0 .5-.1.7-.3A1 1 0 0 0 13 9a1 1 0 0 0-.3-.7A1 1 0 0 0 12 8a1 1 0 0 0-.7.3 1 1 0 0 0-.3.7 1 1 0 0 0 1 1Zm0 6a3 3 0 0 1-1.8-.5A3 3 0 0 1 9.1 14a2 2 0 0 0-.2 0 3 3 0 0 1-2.5-1 3 3 0 0 1-.9-2.2A3 3 0 0 1 6.2 9a3.4 3.4 0 0 1-.5-1 3 3 0 0 1-.2-1 3 3 0 0 1 1-2.2A3 3 0 0 1 8.8 4H9a3 3 0 0 1 1.1-1.4A3 3 0 0 1 12 2a3 3 0 0 1 1.8.5c.5.4.9.9 1.1 1.5h.2a3 3 0 0 1 2.5.8 3 3 0 0 1 .9 2.3c0 .3 0 .7-.2 1a2.8 2.8 0 0 1-.5.9l.5 1 .2 1a3 3 0 0 1-1 2.2 3 3 0 0 1-2.4.9 1.8 1.8 0 0 0-.2 0 3 3 0 0 1-1.1 1.4 3 3 0 0 1-1.8.5Zm0 7a8.7 8.7 0 0 1 .7-3.5 9.2 9.2 0 0 1 2-2.8 9.2 9.2 0 0 1 2.8-2A8.6 8.6 0 0 1 21 14a8.6 8.6 0 0 1-.7 3.5 9.2 9.2 0 0 1-2 2.8 9.2 9.2 0 0 1-2.8 2 8.6 8.6 0 0 1-3.5.7Zm2.5-2.5a6.8 6.8 0 0 0 2.4-1.5 6.8 6.8 0 0 0 1.7-2.6c-1 .4-1.8 1-2.6 1.7a6.8 6.8 0 0 0-1.5 2.4ZM12 23a8.6 8.6 0 0 0-.7-3.5 9.2 9.2 0 0 0-2-2.9 9.2 9.2 0 0 0-2.8-1.9A8.6 8.6 0 0 0 3 14a8.7 8.7 0 0 0 .7 3.5 9.2 9.2 0 0 0 2 2.9 9.2 9.2 0 0 0 2.8 1.9 8.6 8.6 0 0 0 3.5.7Zm-2.5-2.5A6.8 6.8 0 0 1 7.2 19a6.8 6.8 0 0 1-1.6-2.6c.9.4 1.7 1 2.5 1.7a6.8 6.8 0 0 1 1.6 2.4Zm5.9-8.4c.3 0 .5-.1.8-.4.2-.2.3-.5.3-.8 0-.2 0-.4-.2-.6a1.3 1.3 0 0 0-.5-.4l-.8-.4a3 3 0 0 1-.2.5 3.8 3.8 0 0 1-.2.5 2.5 2.5 0 0 1-.3.4 4 4 0 0 1-.4.4l.8.6a.8.8 0 0 0 .3.1h.4ZM15 8.5l.8-.4.5-.4.2-.6a1.1 1.1 0 0 0-.3-.8 1 1 0 0 0-.8-.4 1.1 1.1 0 0 0-.7.2l-.8.6.4.4.3.4a3.9 3.9 0 0 1 .4 1Zm-4-2.3a2.7 2.7 0 0 1 1-.2 2.7 2.7 0 0 1 1 .2l.2-1.1c0-.3-.1-.6-.4-.8A1.2 1.2 0 0 0 12 4c-.3 0-.6.1-.8.3-.3.2-.4.5-.3.8v1.1Zm1 7.8c.3 0 .6-.1.8-.3.3-.2.4-.5.3-.8v-1.1a2.7 2.7 0 0 1-1.1.2 2.7 2.7 0 0 1-1-.2l-.2 1.1c0 .3.1.6.4.8.2.2.5.3.8.3ZM9 8.5a3 3 0 0 1 .4-1l.3-.4.4-.4-.8-.6A.9.9 0 0 0 9 6a1.2 1.2 0 0 0-.3 0c-.4 0-.6 0-.9.3a1.1 1.1 0 0 0-.3.8c0 .2 0 .4.2.6l.5.4.9.4Zm-.3 3.6H9l.3-.2.8-.6a5.5 5.5 0 0 1-.4-.4 2.1 2.1 0 0 1-.3-.4 3.8 3.8 0 0 1-.2-.5 3 3 0 0 1-.1-.5l-1 .4-.4.4a1.1 1.1 0 0 0-.2.6c0 .3.2.6.4.8.2.2.4.4.7.4Z"/></svg>',
|
||||
'image-enhancements': '<svg height="24" width="24"><path d="M5.3 21a2 2 0 0 1-1.5-.6 2 2 0 0 1-.6-1.4V5c0-.6.2-1 .6-1.4A2 2 0 0 1 5.2 3H13v2H5.2v14h14v-8h2v8c0 .6-.1 1-.5 1.4a2 2 0 0 1-1.4.6z"/><path d="M11 9a4 4 0 0 0 4 4 4 4 0 0 0-4 4 4 4 0 0 0-4-4 4 4 0 0 0 4-4zm5.5-6c.2 2.1 2.2 4.1 4.5 4.5-2.3.4-4.3 2.4-4.5 4.5A5.2 5.2 0 0 0 12 7.5 5.2 5.2 0 0 0 16.5 3z"/></svg>',
|
||||
'image-options': '<svg width="24" height="24"><path d="M6 10a2 2 0 0 0-2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2 2 2 0 0 0-2-2Zm12 0a2 2 0 0 0-2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2 2 2 0 0 0-2-2Zm-6 0a2 2 0 0 0-2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2 2 2 0 0 0-2-2Z" fill-rule="nonzero"/></svg>',
|
||||
'image': '<svg width="24" height="24"><path d="m5 15.7 3.3-3.2c.3-.3.7-.3 1 0L12 15l4.1-4c.3-.4.8-.4 1 0l2 1.9V5H5v10.7ZM5 18V19h3l2.8-2.9-2-2L5 17.9Zm14-3-2.5-2.4-6.4 6.5H19v-4ZM4 3h16c.6 0 1 .4 1 1v16c0 .6-.4 1-1 1H4a1 1 0 0 1-1-1V4c0-.6.4-1 1-1Zm6 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z" fill-rule="nonzero"/></svg>',
|
||||
'import-word': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M7 3h7.4L19 7.6V15h-2V9h-4V5H5c0-1.1.9-2 2-2Z"/><path d="M9.5 7A1.5 1.5 0 0 1 11 8.4v7.1A1.5 1.5 0 0 1 9.6 17H2.5A1.5 1.5 0 0 1 1 15.6V8.5A1.5 1.5 0 0 1 2.4 7h7.1Zm-1 2.8-1 2.6-1-2.5v-.1a.6.6 0 0 0-1 0l-.1.1-.9 2.5-1-2.5v-.1a.6.6 0 0 0-1 .4v.1l1.5 4v.1a.6.6 0 0 0 1 0v-.1l1-2.5.9 2.5v.1a.6.6 0 0 0 1 0H8l1.6-4v-.2a.6.6 0 0 0-1.1-.4Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M11.4 18.2a1 1 0 0 0 1.2 1.6l1.4-1V22a1 1 0 1 0 2 0v-3.1l1.4 1a1 1 0 0 0 1.2-1.7L15 15.8l-3.6 2.4Z"/></svg>',
|
||||
'improve-writing': '<svg height="24" width="24"><path d="M14.8 4.1c-1 0-2.2.5-3 1.3L6.5 11a.8.8 0 0 0-.2.6V16l7.9-8.3.7.8.6.7-4.4 4.6h4.7l2-2.2a4.5 4.5 0 0 0 1-1.7l2 .6a6.5 6.5 0 0 1-1.6 2.5L14 18.7a2.7 2.7 0 0 1-1 .7 2.6 2.6 0 0 1-1 .2H5.8L3.4 22 2 20.5l2.4-2.4a2 2 0 0 1 0-.5v-6c0-.8.2-1.5.7-2L10.5 4A6 6 0 0 1 14.8 2v2zM7.6 17.5H12a.8.8 0 0 0 .5-.2l1.3-1.4H9.2z" fill-rule="evenodd"/><path d="M18.8 2.2a.3.3 0 0 1 .6 0 4 4 0 0 0 2.4 2.5c.3.1.3.5 0 .7h-.2a4.1 4.1 0 0 0-2.2 2.4.3.3 0 0 1-.6 0 4.1 4.1 0 0 0-2.2-2.4h-.2c-.3-.2-.3-.6 0-.7 1.1-.4 2-1.3 2.4-2.5z"/></svg>',
|
||||
'indent': '<svg width="24" height="24"><path d="M7 5h12c.6 0 1 .4 1 1s-.4 1-1 1H7a1 1 0 1 1 0-2Zm5 4h7c.6 0 1 .4 1 1s-.4 1-1 1h-7a1 1 0 0 1 0-2Zm0 4h7c.6 0 1 .4 1 1s-.4 1-1 1h-7a1 1 0 0 1 0-2Zm-5 4h12a1 1 0 0 1 0 2H7a1 1 0 0 1 0-2Zm-2.6-3.8L6.2 12l-1.8-1.2a1 1 0 0 1 1.2-1.6l3 2a1 1 0 0 1 0 1.6l-3 2a1 1 0 1 1-1.2-1.6Z" fill-rule="evenodd"/></svg>',
|
||||
'info': '<svg width="24" height="24"><path d="M12 4a7.8 7.8 0 0 1 5.7 2.3A8 8 0 1 1 12 4Zm-1 3v2h2V7h-2Zm3 10v-1h-1v-5h-3v1h1v4h-1v1h4Z" fill-rule="evenodd"/></svg>',
|
||||
'insert-character': '<svg width="24" height="24"><path d="M15 18h4l1-2v4h-6v-3.3l1.4-1a6 6 0 0 0 1.8-2.9 6.3 6.3 0 0 0-.1-4.1 5.8 5.8 0 0 0-3-3.2c-.6-.3-1.3-.5-2.1-.5a5.1 5.1 0 0 0-3.9 1.8 6.3 6.3 0 0 0-1.3 6 6.2 6.2 0 0 0 1.8 3l1.4.9V20H4v-4l1 2h4v-.5l-2-1L5.4 15A6.5 6.5 0 0 1 4 11c0-1 .2-1.9.6-2.7A7 7 0 0 1 6.3 6C7.1 5.4 8 5 9 4.5c1-.3 2-.5 3.1-.5a8.8 8.8 0 0 1 5.7 2 7 7 0 0 1 1.7 2.3 6 6 0 0 1 .2 4.8c-.2.7-.6 1.3-1 1.9a7.6 7.6 0 0 1-3.6 2.5v.5Z" fill-rule="evenodd"/></svg>',
|
||||
'insert-time': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="M12 19a7 7 0 1 0 0-14 7 7 0 0 0 0 14Zm0 2a9 9 0 1 1 0-18 9 9 0 0 1 0 18Z"/><path d="M16 12h-3V7c0-.6-.4-1-1-1a1 1 0 0 0-1 1v7h5c.6 0 1-.4 1-1s-.4-1-1-1Z"/></g></svg>',
|
||||
'instagram': '<svg height="24" width="24"><path clip-rule="evenodd" d="M10.1 9a4 4 0 1 1 4.4 6.6A4 4 0 0 1 10.1 9zm2.2 1.3a2 2 0 1 0 0 4 2 2 0 0 0 0-4z" fill-rule="evenodd"/><path d="M16 9.3a1.3 1.3 0 1 0 0-2.6 1.3 1.3 0 0 0 0 2.6z"/><path clip-rule="evenodd" d="M3 6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3zm3-1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1z" fill-rule="evenodd"/></svg>',
|
||||
'invert': '<svg height="24" width="24"><path d="M5 4 3.6 5.4l3.9 4C6.6 10.7 6 12.3 6 14a6 6 0 0 0 10.3 4.2l1.9 1.8 1.4-1.4z" fill-rule="evenodd"/><path d="M13.4 8.4c.9 1 1.6 2.1 2 3.2l2.6 2.6V14c0-5-6-10-6-10s-1 .8-2.1 2l1.4 1.5.7-.7 1.4 1.6z"/></svg>',
|
||||
'italic': '<svg width="24" height="24"><path d="m16.7 4.7-.1.9h-.3c-.6 0-1 0-1.4.3-.3.3-.4.6-.5 1.1l-2.1 9.8v.6c0 .5.4.8 1.4.8h.2l-.2.8H8l.2-.8h.2c1.1 0 1.8-.5 2-1.5l2-9.8.1-.5c0-.6-.4-.8-1.4-.8h-.3l.2-.9h5.8Z" fill-rule="evenodd"/></svg>',
|
||||
'language': '<svg width="24" height="24"><path d="M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18Zm4.3 13.3c-.5 1-1.2 2-2 2.9a7.5 7.5 0 0 0 3.2-2.1l-.2-.2a6 6 0 0 0-1-.6Zm-8.6 0c-.5.2-.9.5-1.2.8.9 1 2 1.7 3.2 2a10 10 0 0 1-2-2.8Zm3.6-.8c-.8 0-1.6.1-2.2.3.5 1 1.2 1.9 2.1 2.7Zm1.5 0v3c.9-.8 1.6-1.7 2.1-2.7-.6-.2-1.4-.3-2.1-.3Zm-6-2.7H4.5c.2 1 .5 2.1 1 3h.3l1.3-1a10 10 0 0 1-.3-2Zm12.7 0h-2.3c0 .7-.1 1.4-.3 2l1.6 1.1c.5-1 .9-2 1-3.1Zm-3.8 0h-3V14c1 0 2 .1 2.7.4.2-.5.3-1 .3-1.6Zm-4.4 0h-3l.3 1.6c.8-.3 1.7-.4 2.7-.4v-1.3Zm-5.5-5c-.7 1-1.1 2.2-1.3 3.5h2.3c0-1 .2-1.8.5-2.6l-1.5-1Zm2.9 1.4v.1c-.2.6-.4 1.3-.4 2h3V9.4c-1 0-1.8-.1-2.6-.3Zm6.6 0h-.1l-2.4.3v1.8h3l-.5-2.1Zm3-1.4-.3.1-1.3.8c.3.8.5 1.6.5 2.6h2.3a7.5 7.5 0 0 0-1.3-3.5Zm-9 0 2 .2V5.5a9 9 0 0 0-2 2.2Zm3.5-2.3V8c.6 0 1.3 0 1.9-.2a9 9 0 0 0-2-2.3Zm-3-.7h-.1c-1.1.4-2.1 1-3 1.8l1.2.7a10 10 0 0 1 1.9-2.5Zm4.4 0 .1.1a10 10 0 0 1 1.8 2.4l1.1-.7a7.5 7.5 0 0 0-3-1.8Z"/></svg>',
|
||||
'line-height': '<svg width="24" height="24"><path d="M21 5a1 1 0 0 1 .1 2H13a1 1 0 0 1-.1-2H21zm0 4a1 1 0 0 1 .1 2H13a1 1 0 0 1-.1-2H21zm0 4a1 1 0 0 1 .1 2H13a1 1 0 0 1-.1-2H21zm0 4a1 1 0 0 1 .1 2H13a1 1 0 0 1-.1-2H21zM7 3.6l3.7 3.7a1 1 0 0 1-1.3 1.5h-.1L8 7.3v9.2l1.3-1.3a1 1 0 0 1 1.3 0h.1c.4.4.4 1 0 1.3v.1L7 20.4l-3.7-3.7a1 1 0 0 1 1.3-1.5h.1L6 16.7V7.4L4.7 8.7a1 1 0 0 1-1.3 0h-.1a1 1 0 0 1 0-1.3v-.1L7 3.6z"/></svg>',
|
||||
'line': '<svg width="24" height="24"><path d="m15 9-8 8H4v-3l8-8 3 3Zm1-1-3-3 1-1h1c-.2 0 0 0 0 0l2 2s0 .2 0 0v1l-1 1ZM4 18h16v2H4v-2Z" fill-rule="evenodd"/></svg>',
|
||||
'link': '<svg width="24" height="24"><path d="M6.2 12.3a1 1 0 0 1 1.4 1.4l-2 2a2 2 0 1 0 2.6 2.8l4.8-4.8a1 1 0 0 0 0-1.4 1 1 0 1 1 1.4-1.3 2.9 2.9 0 0 1 0 4L9.6 20a3.9 3.9 0 0 1-5.5-5.5l2-2Zm11.6-.6a1 1 0 0 1-1.4-1.4l2-2a2 2 0 1 0-2.6-2.8L11 10.3a1 1 0 0 0 0 1.4A1 1 0 1 1 9.6 13a2.9 2.9 0 0 1 0-4L14.4 4a3.9 3.9 0 0 1 5.5 5.5l-2 2Z" fill-rule="nonzero"/></svg>',
|
||||
'list-bull-circle': '<svg height="48" width="48"><path d="M8 14a3 3 0 1 0 6 0 3 3 0 0 0-6 0zm5 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM8 24a3 3 0 1 0 6 0 3 3 0 0 0-6 0zm5 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM8 34a3 3 0 1 0 6 0 3 3 0 0 0-6 0zm5 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0z" fill-rule="evenodd"/><path d="M18 12h22v4H18zm0 10h22v4H18zm0 10h22v4H18z" opacity=".2"/></svg>',
|
||||
'list-bull-default': '<svg height="48" width="48"><path d="M14 14a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/><path d="M12 24a3 3 0 1 0 6 0 3 3 0 0 0-6 0zm5 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0z" fill-rule="evenodd"/><path d="M16 31h6v6h-6z"/><path d="M16 31h6v6h-6z"/><path d="M18 12h22v4H18zm4 10h18v4H22zm4 10h14v4H26z" opacity=".2"/></svg>',
|
||||
'list-bull-disc': '<svg height="48" width="48"><path d="M14 14a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm0 10a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm0 10a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/><path d="M18 12h22v4H18zm0 10h22v4H18zm0 10h22v4H18z" opacity=".2"/></svg>',
|
||||
'list-bull-square': '<svg height="48" width="48"><path d="M8 21h6v6H8zm0 10h6v6H8zm0-20h6v6H8z"/><path d="M18 12h22v4H18zm0 10h22v4H18zm0 10h22v4H18z" opacity=".2"/></svg>',
|
||||
'list-num-default-rtl': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M8 12h22v4H8zM8 22h22v4H8zM8 32h22v4H8z"/><path d="M37.4 17v-4.8h-.1l-1.5 1v-1.1l1.6-1.1h1.2v6zM33.3 17.1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7zm1.7 5.7c0-1.2 1-2 2.2-2 1.3 0 2.1.8 2.1 1.8 0 .7-.3 1.2-1.3 2.2l-1.2 1v.2h2.6v1h-4.3v-.9l2-1.9c.8-.8 1-1.1 1-1.5 0-.5-.4-.8-1-.8-.5 0-.9.3-.9.9H35zm-1.7 4.3c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7zm3.2 7.3v-1h.7c.6 0 1-.3 1-.8 0-.4-.4-.7-1-.7s-1 .3-1 .8H35c0-1.1 1-1.8 2.2-1.8 1.2 0 2.1.6 2.1 1.6 0 .7-.4 1.2-1 1.3v.1c.7.1 1.3.7 1.3 1.4 0 1-1 1.9-2.4 1.9-1.3 0-2.2-.8-2.3-2h1.2c0 .6.5 1 1.1 1 .6 0 1-.4 1-1 0-.5-.3-.8-1-.8h-.7zm-3.3 2.7c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .8.3.8.7 0 .4-.3.7-.8.7z"/></g></svg>',
|
||||
'list-num-default': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/><path d="M10 17v-4.8l-1.5 1v-1.1l1.6-1h1.2V17h-1.2Zm3.6.1c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .7.3.7.7 0 .4-.2.7-.7.7Zm-5 5.7c0-1.2.8-2 2.1-2s2.1.8 2.1 1.8c0 .7-.3 1.2-1.4 2.2l-1.1 1v.2h2.6v1H8.6v-.9l2-1.9c.8-.8 1-1.1 1-1.5 0-.5-.4-.8-1-.8-.5 0-.9.3-.9.9H8.5Zm6.3 4.3c-.5 0-.7-.3-.7-.7 0-.4.2-.7.7-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7ZM10 34.4v-1h.7c.6 0 1-.3 1-.8 0-.4-.4-.7-1-.7s-1 .3-1 .8H8.6c0-1.1 1-1.8 2.2-1.8 1.3 0 2.1.6 2.1 1.6 0 .7-.4 1.2-1 1.3v.1c.8.1 1.3.7 1.3 1.4 0 1-1 1.9-2.4 1.9-1.3 0-2.2-.8-2.3-2h1.2c0 .6.5 1 1.1 1 .7 0 1-.4 1-1 0-.5-.3-.8-1-.8h-.7Zm4.7 2.7c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .8.3.8.7 0 .4-.3.7-.8.7Z"/></g></svg>',
|
||||
'list-num-lower-alpha-rtl': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M8 12h22v4H8zM8 22h22v4H8zM8 32h22v4H8z"/><path d="M36.5 16c-.9 0-1.5-.5-1.5-1.3s.6-1.3 1.8-1.4h1v-.4c0-.4-.2-.6-.7-.6-.4 0-.7.1-.8.4h-1.1c0-.8.8-1.4 2-1.4S39 12 39 13V16h-1.2v-.6c-.3.4-.8.7-1.4.7Zm.4-.8c.6 0 1-.4 1-.9V14h-1c-.5.1-.7.3-.7.6 0 .4.3.6.7.6ZM33.1 16.1c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .8.3.8.7 0 .4-.3.7-.8.7ZM37.7 26c-.7 0-1.2-.2-1.5-.7v.7H35v-6.3h1.2v2.5c.3-.5.8-.9 1.5-.9 1.1 0 1.8 1 1.8 2.4 0 1.5-.7 2.4-1.8 2.4Zm-.5-3.6c-.6 0-1 .5-1 1.3s.4 1.4 1 1.4c.7 0 1-.6 1-1.4 0-.8-.3-1.3-1-1.3ZM33.2 26.1c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .8.3.8.7 0 .4-.3.7-.8.7zm6 7h-1c-.1-.5-.4-.8-1-.8s-1 .5-1 1.4c0 1 .4 1.4 1 1.4.5 0 .9-.2 1-.7h1c0 1-.8 1.7-2 1.7-1.4 0-2.2-.9-2.2-2.4s.8-2.4 2.2-2.4c1.2 0 2 .7 2 1.7zm-6.1 3c-.5 0-.7-.3-.7-.7 0-.4.2-.7.7-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7z"/></g></svg>',
|
||||
'list-num-lower-alpha': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/><path d="M10.3 15.2c.5 0 1-.4 1-.9V14h-1c-.5.1-.8.3-.8.6 0 .4.3.6.8.6Zm-.4.9c-1 0-1.5-.6-1.5-1.4 0-.8.6-1.3 1.7-1.4h1.1v-.4c0-.4-.2-.6-.7-.6-.5 0-.8.1-.9.4h-1c0-.8.8-1.4 2-1.4 1.1 0 1.8.6 1.8 1.6V16h-1.1v-.6h-.1c-.2.4-.7.7-1.3.7Zm4.6 0c-.5 0-.7-.3-.7-.7 0-.4.2-.7.7-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Zm-3.2 10c-.6 0-1.2-.3-1.4-.8v.7H8.5v-6.3H10v2.5c.3-.5.8-.9 1.4-.9 1.2 0 1.9 1 1.9 2.4 0 1.5-.7 2.4-1.9 2.4Zm-.4-3.7c-.7 0-1 .5-1 1.3s.3 1.4 1 1.4c.6 0 1-.6 1-1.4 0-.8-.4-1.3-1-1.3Zm4 3.7c-.5 0-.7-.3-.7-.7 0-.4.2-.7.7-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Zm-2.2 7h-1.2c0-.5-.4-.8-.9-.8-.6 0-1 .5-1 1.4 0 1 .4 1.4 1 1.4.5 0 .8-.2 1-.7h1c0 1-.8 1.7-2 1.7-1.4 0-2.2-.9-2.2-2.4s.8-2.4 2.2-2.4c1.2 0 2 .7 2 1.7Zm1.8 3c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Z"/></g></svg>',
|
||||
'list-num-lower-greek-rtl': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M8 12h22v4H8zM8 22h22v4H8zM8 32h22v4H8z"/><path d="M37.4 16c-1.2 0-2-.8-2-2.3 0-1.5.8-2.4 2-2.4.6 0 1 .4 1.3 1v-.9H40v3.2c0 .4.1.5.4.5h.2v.9h-.6c-.6 0-1-.2-1-.7h-.2c-.2.4-.7.8-1.3.8Zm.3-1c.6 0 1-.5 1-1.3s-.4-1.3-1-1.3-1 .5-1 1.3.4 1.4 1 1.4ZM33.3 16.1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7ZM36 21.9c0-1.5.8-2.3 2.1-2.3 1.2 0 2 .6 2 1.6 0 .6-.3 1-.9 1.3.9.3 1.3.8 1.3 1.7 0 1.2-.7 1.9-1.8 1.9-.6 0-1.1-.3-1.4-.8v2.2H36V22Zm1.8 1.2v-1h.3c.5 0 .9-.2.9-.7 0-.5-.3-.8-.9-.8-.5 0-.8.3-.8 1v2.2c0 .8.4 1.3 1 1.3s1-.4 1-1-.4-1-1.2-1h-.3ZM33.3 26.1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7ZM37.1 34.6 34.8 30h1.4l1.7 3.5 1.7-3.5h1.1l-2.2 4.6v.1c.5.8.7 1.4.7 1.8 0 .4-.2.8-.4 1-.2.2-.6.3-1 .3-.9 0-1.3-.4-1.3-1.2 0-.5.2-1 .5-1.7l.1-.2Zm.7 1a2 2 0 0 0-.4.9c0 .3.1.4.4.4.3 0 .4-.1.4-.4 0-.2-.1-.6-.4-1ZM33.3 36.1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Z"/></g></svg>',
|
||||
'list-num-lower-greek': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/><path d="M10.5 15c.7 0 1-.5 1-1.3s-.3-1.3-1-1.3c-.5 0-.9.5-.9 1.3s.4 1.4 1 1.4Zm-.3 1c-1.1 0-1.8-.8-1.8-2.3 0-1.5.7-2.4 1.8-2.4.7 0 1.1.4 1.3 1h.1v-.9h1.2v3.2c0 .4.1.5.4.5h.2v.9h-.6c-.6 0-1-.2-1.1-.7h-.1c-.2.4-.7.8-1.4.8Zm5 .1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.7-.7.5 0 .8.3.8.7 0 .4-.3.7-.8.7Zm-4.9 7v-1h.3c.6 0 1-.2 1-.7 0-.5-.4-.8-1-.8-.5 0-.8.3-.8 1v2.2c0 .8.4 1.3 1.1 1.3.6 0 1-.4 1-1s-.5-1-1.3-1h-.3ZM8.6 22c0-1.5.7-2.3 2-2.3 1.2 0 2 .6 2 1.6 0 .6-.3 1-.8 1.3.8.3 1.3.8 1.3 1.7 0 1.2-.8 1.9-1.9 1.9-.6 0-1.1-.3-1.3-.8v2.2H8.5V22Zm6.2 4.2c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .7.3.7.7 0 .4-.2.7-.7.7Zm-4.5 8.5L8 30h1.4l1.7 3.5 1.7-3.5h1.1l-2.2 4.6v.1c.5.8.7 1.4.7 1.8 0 .4-.1.8-.4 1-.2.2-.6.3-1 .3-.9 0-1.3-.4-1.3-1.2 0-.5.2-1 .5-1.7l.1-.2Zm.7 1a2 2 0 0 0-.4.9c0 .3.1.4.4.4.3 0 .4-.1.4-.4 0-.2-.1-.6-.4-1Zm4.5.5c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Z"/></g></svg>',
|
||||
'list-num-lower-roman-rtl': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M8 12h22v4H8zM8 22h22v4H8zM8 32h22v4H8z"/><path d="M32.9 16v-1.2h-1.3V16H33Zm0 10v-1.2h-1.3V26H33Zm0 10v-1.2h-1.3V36H33Z"/><path fill-rule="nonzero" d="M36 21h-1.5v5H36zM36 31h-1.5v5H36zM39 21h-1.5v5H39zM39 31h-1.5v5H39zM42 31h-1.5v5H42zM36 11h-1.5v5H36zM36 19h-1.5v1H36zM36 29h-1.5v1H36zM39 19h-1.5v1H39zM39 29h-1.5v1H39zM42 29h-1.5v1H42zM36 9h-1.5v1H36z"/></g></svg>',
|
||||
'list-num-lower-roman': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/><path d="M15.1 16v-1.2h1.3V16H15Zm0 10v-1.2h1.3V26H15Zm0 10v-1.2h1.3V36H15Z"/><path fill-rule="nonzero" d="M12 21h1.5v5H12zM12 31h1.5v5H12zM9 21h1.5v5H9zM9 31h1.5v5H9zM6 31h1.5v5H6zM12 11h1.5v5H12zM12 19h1.5v1H12zM12 29h1.5v1H12zM9 19h1.5v1H9zM9 29h1.5v1H9zM6 29h1.5v1H6zM12 9h1.5v1H12z"/></g></svg>',
|
||||
'list-num-upper-alpha-rtl': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M8 12h22v4H8zM8 22h22v4H8zM8 32h22v4H8z"/><path d="m39.3 17-.5-1.4h-2l-.5 1.4H35l2-6h1.6l2 6h-1.3Zm-1.6-4.7-.7 2.3h1.6l-.8-2.3ZM33.4 17c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .7.3.7.7 0 .4-.2.7-.7.7Zm4.7 9.9h-2.7v-6H38c1.2 0 1.9.6 1.9 1.5 0 .6-.5 1.2-1 1.3.7.1 1.3.7 1.3 1.5 0 1-.8 1.7-2 1.7Zm-1.4-5v1.5h1c.6 0 1-.3 1-.8 0-.4-.4-.7-1-.7h-1Zm0 4h1.1c.7 0 1.1-.3 1.1-.8 0-.6-.4-.9-1.1-.9h-1.1V26ZM33 27.1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Zm4.9 10c-1.8 0-2.8-1.1-2.8-3.1s1-3.1 2.8-3.1c1.4 0 2.5.9 2.6 2.2h-1.3c0-.7-.6-1.1-1.3-1.1-1 0-1.6.7-1.6 2s.6 2 1.6 2c.7 0 1.2-.4 1.4-1h1.2c-.1 1.3-1.2 2.2-2.6 2.2Zm-4.5 0c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Z"/></g></svg>',
|
||||
'list-num-upper-alpha': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/><path d="m12.6 17-.5-1.4h-2L9.5 17H8.3l2-6H12l2 6h-1.3ZM11 12.3l-.7 2.3h1.6l-.8-2.3Zm4.7 4.8c-.4 0-.7-.3-.7-.7 0-.4.3-.7.7-.7.5 0 .7.3.7.7 0 .4-.2.7-.7.7ZM11.4 27H8.7v-6h2.6c1.2 0 1.9.6 1.9 1.5 0 .6-.5 1.2-1 1.3.7.1 1.3.7 1.3 1.5 0 1-.8 1.7-2 1.7ZM10 22v1.5h1c.6 0 1-.3 1-.8 0-.4-.4-.7-1-.7h-1Zm0 4H11c.7 0 1.1-.3 1.1-.8 0-.6-.4-.9-1.1-.9H10V26Zm5.4 1.1c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Zm-4.1 10c-1.8 0-2.8-1.1-2.8-3.1s1-3.1 2.8-3.1c1.4 0 2.5.9 2.6 2.2h-1.3c0-.7-.6-1.1-1.3-1.1-1 0-1.6.7-1.6 2s.6 2 1.6 2c.7 0 1.2-.4 1.4-1h1.2c-.1 1.3-1.2 2.2-2.6 2.2Zm4.5 0c-.5 0-.8-.3-.8-.7 0-.4.3-.7.8-.7.4 0 .7.3.7.7 0 .4-.3.7-.7.7Z"/></g></svg>',
|
||||
'list-num-upper-roman-rtl': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M8 12h22v4H8zM8 22h22v4H8zM8 32h22v4H8z"/><path d="M31.6 17v-1.2H33V17h-1.3Zm0 10v-1.2H33V27h-1.3Zm0 10v-1.2H33V37h-1.3Z"/><path fill-rule="nonzero" d="M34.5 20H36v7h-1.5zM34.5 30H36v7h-1.5zM37.5 20H39v7h-1.5zM37.5 30H39v7h-1.5zM40.5 30H42v7h-1.5zM34.5 10H36v7h-1.5z"/></g></svg>',
|
||||
'list-num-upper-roman': '<svg width="48" height="48"><g fill-rule="evenodd"><path opacity=".2" d="M18 12h22v4H18zM18 22h22v4H18zM18 32h22v4H18z"/><path d="M15.1 17v-1.2h1.3V17H15Zm0 10v-1.2h1.3V27H15Zm0 10v-1.2h1.3V37H15Z"/><path fill-rule="nonzero" d="M12 20h1.5v7H12zM12 30h1.5v7H12zM9 20h1.5v7H9zM9 30h1.5v7H9zM6 30h1.5v7H6zM12 10h1.5v7H12z"/></g></svg>',
|
||||
'lock': '<svg width="24" height="24"><path d="M16.3 11c.2 0 .3 0 .5.2l.2.6v7.4c0 .3 0 .4-.2.6l-.6.2H7.8c-.3 0-.4 0-.6-.2a.7.7 0 0 1-.2-.6v-7.4c0-.3 0-.4.2-.6l.5-.2H8V8c0-.8.3-1.5.9-2.1.6-.6 1.3-.9 2.1-.9h2c.8 0 1.5.3 2.1.9.6.6.9 1.3.9 2.1v3h.3ZM10 8v3h4V8a1 1 0 0 0-.3-.7A1 1 0 0 0 13 7h-2a1 1 0 0 0-.7.3 1 1 0 0 0-.3.7Z" fill-rule="evenodd"/></svg>',
|
||||
'ltr': '<svg width="24" height="24"><path d="M11 5h7a1 1 0 0 1 0 2h-1v11a1 1 0 0 1-2 0V7h-2v11a1 1 0 0 1-2 0v-6c-.5 0-1 0-1.4-.3A3.4 3.4 0 0 1 7.8 10a3.3 3.3 0 0 1 0-2.8 3.4 3.4 0 0 1 1.8-1.8L11 5ZM4.4 16.2 6.2 15l-1.8-1.2a1 1 0 0 1 1.2-1.6l3 2a1 1 0 0 1 0 1.6l-3 2a1 1 0 1 1-1.2-1.6Z" fill-rule="evenodd"/></svg>',
|
||||
'math-equation': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M9 4.8c.1-.5.5-.8 1-.8h10a1 1 0 1 1 0 2h-9.2L8.3 19.2a1 1 0 0 1-1.7.4l-3.4-4.2a1 1 0 0 1 1.6-1.2l2 2.5L9 4.8Zm9.7 5.5c.4.4.4 1 0 1.4L17 13.5l1.8 1.8a1 1 0 1 1-1.4 1.4L15.5 15l-1.8 1.8a1 1 0 0 1-1.4-1.4l1.8-1.8-1.8-1.8a1 1 0 0 1 1.4-1.4l1.8 1.8 1.8-1.8a1 1 0 0 1 1.4 0Z"/></svg>',
|
||||
'mentions': '<svg height="24" width="24"><path d="M12 21a8.8 8.8 0 0 1-3.5-.7 9 9 0 0 1-2.9-2 9 9 0 0 1-1.9-2.8A8.8 8.8 0 0 1 3 12c0-1.3.2-2.4.7-3.5a9 9 0 0 1 4.8-4.8A8.8 8.8 0 0 1 12 3c1.3 0 2.4.2 3.5.7a9.1 9.1 0 0 1 4.8 4.8A8.7 8.7 0 0 1 21 12v1.4a3 3 0 0 1-.9 2.2 3 3 0 0 1-2.2.9c-.5 0-1-.1-1.5-.4a3.8 3.8 0 0 1-1.1-1 4.8 4.8 0 0 1-1.5 1 4.3 4.3 0 0 1-1.8.4c-1.2 0-2.3-.4-3.2-1.3-.9-.9-1.3-2-1.3-3.2s.4-2.3 1.3-3.2c.9-.9 2-1.3 3.2-1.3s2.3.4 3.2 1.3c.9.9 1.3 2 1.3 3.2v1.4c0 .4.1.7.4 1 .3.3.6.4 1 .4s.7-.1 1-.4c.3-.3.4-.6.4-1V12c0-2-.7-3.8-2.1-5.2S14 4.7 12 4.7s-3.8.7-5.2 2.1S4.7 10 4.7 12s.7 3.8 2.1 5.2 3.2 2.1 5.2 2.1h4.5V21zm0-6.2c.8 0 1.4-.3 2-.8a2.7 2.7 0 0 0 .8-2c0-.8-.3-1.4-.8-2a2.7 2.7 0 0 0-2-.8c-.8 0-1.4.3-2 .8a2.7 2.7 0 0 0-.8 2c0 .8.3 1.4.8 2a2.7 2.7 0 0 0 2 .8z"/></svg>',
|
||||
'minus': '<svg width="24" height="24"><path d="M19 11a1 1 0 0 1 .1 2H5a1 1 0 0 1-.1-2H19Z"/></svg>',
|
||||
'more-drawer': '<svg width="24" height="24"><path d="M6 10a2 2 0 0 0-2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2 2 2 0 0 0-2-2Zm12 0a2 2 0 0 0-2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2 2 2 0 0 0-2-2Zm-6 0a2 2 0 0 0-2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2 2 2 0 0 0-2-2Z" fill-rule="nonzero"/></svg>',
|
||||
'new-chat': '<svg height="24" width="24"><path d="M12 5H6v13.7h12v-7.9h2v7.9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6z"/><path d="M17 3.3a1 1 0 0 1 1.3 0l1.4 1.3a1 1 0 0 1 0 1.4l-5.2 5a1 1 0 0 1-.5.3l-3 .6.5-3a1 1 0 0 1 .3-.5L17 3.3z"/></svg>',
|
||||
'new-document': '<svg width="24" height="24"><path d="M14.4 3H7a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h10a2 2 0 0 0 2-2V7.6L14.4 3ZM17 19H7V5h6v4h4v10Z" fill-rule="nonzero"/></svg>',
|
||||
'new-tab': '<svg width="24" height="24"><path d="m15 13 2-2v8H5V7h8l-2 2H7v8h8v-4Zm4-8v5.5l-2-2-5.6 5.5H10v-1.4L15.5 7l-2-2H19Z" fill-rule="evenodd"/></svg>',
|
||||
'non-breaking': '<svg width="24" height="24"><path d="M11 11H8a1 1 0 1 1 0-2h3V6c0-.6.4-1 1-1s1 .4 1 1v3h3c.6 0 1 .4 1 1s-.4 1-1 1h-3v3c0 .6-.4 1-1 1a1 1 0 0 1-1-1v-3Zm10 4v5H3v-5c0-.6.4-1 1-1s1 .4 1 1v3h14v-3c0-.6.4-1 1-1s1 .4 1 1Z" fill-rule="evenodd"/></svg>',
|
||||
'notice': '<svg width="24" height="24"><path d="M15.5 4 20 8.5v7L15.5 20h-7L4 15.5v-7L8.5 4h7ZM13 17v-2h-2v2h2Zm0-4V7h-2v6h2Z" fill-rule="evenodd" clip-rule="evenodd"/></svg>',
|
||||
'onedrive': '<svg height="24" width="24"><path d="M4 13.7h1zm16 .7h-1zm-11.7-4A4.4 4.4 0 0 1 12.1 8V6a6.4 6.4 0 0 0-5.6 3.4l1.8 1zM12.1 8c2 0 3.6 1.4 4 2.8l2-.5A6.3 6.3 0 0 0 12 6v2zm8.1 6.8-9.8-5.5-1 1.8 9.8 5.5 1-1.8zm-9.8-5.5A5 5 0 0 0 3 13.7h2A3 3 0 0 1 9.5 11l1-1.8zM3 13.7a5 5 0 0 0 1 2.8l1.5-1.1a3 3 0 0 1-.5-1.7zm15.8 1.6a2.3 2.3 0 0 1-2 1.4v2a4.3 4.3 0 0 0 3.9-2.6zm-2 1.4H8v2h8.7zm-8.8 0a3 3 0 0 1-2.5-1.3L4 16.5a5 5 0 0 0 4 2.2v-2zm12.7-.6c.2-.6.3-1.1.3-1.7h-2c0 .3 0 .6-.2.9zm.3-1.7c0-3-3.1-5.1-6-4l.8 1.9a2.3 2.3 0 0 1 3.2 2h2zm-6-4L4.4 15l.8 1.9 10.7-4.6-.7-1.8z"/></svg>',
|
||||
'ordered-list-rtl': '<svg width="24" height="24"><path d="M6 17h8a1 1 0 0 1 0 2H6a1 1 0 0 1 0-2Zm0-6h8a1 1 0 0 1 0 2H6a1 1 0 0 1 0-2Zm0-6h8a1 1 0 0 1 0 2H6a1 1 0 1 1 0-2Zm13-1v3.5a.5.5 0 1 1-1 0V5h-.5a.5.5 0 1 1 0-1H19Zm-1 8.8.2.2h1.3a.5.5 0 1 1 0 1h-1.6a1 1 0 0 1-.9-1V13c0-.4.3-.8.6-1l1.2-.4.2-.3a.2.2 0 0 0-.2-.2h-1.3a.5.5 0 0 1-.5-.5c0-.3.2-.5.5-.5h1.6c.5 0 .9.4.9 1v.1c0 .4-.3.8-.6 1l-1.2.4-.2.3Zm2 4.2v2c0 .6-.4 1-1 1h-1.5a.5.5 0 0 1 0-1h1.2a.3.3 0 1 0 0-.6h-1.3a.4.4 0 1 1 0-.8h1.3a.3.3 0 0 0 0-.6h-1.2a.5.5 0 1 1 0-1H19c.6 0 1 .4 1 1Z" fill-rule="evenodd"/></svg>',
|
||||
'ordered-list': '<svg width="24" height="24"><path d="M10 17h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0-6h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0-6h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 1 1 0-2ZM6 4v3.5c0 .3-.2.5-.5.5a.5.5 0 0 1-.5-.5V5h-.5a.5.5 0 0 1 0-1H6Zm-1 8.8.2.2h1.3c.3 0 .5.2.5.5s-.2.5-.5.5H4.9a1 1 0 0 1-.9-1V13c0-.4.3-.8.6-1l1.2-.4.2-.3a.2.2 0 0 0-.2-.2H4.5a.5.5 0 0 1-.5-.5c0-.3.2-.5.5-.5h1.6c.5 0 .9.4.9 1v.1c0 .4-.3.8-.6 1l-1.2.4-.2.3ZM7 17v2c0 .6-.4 1-1 1H4.5a.5.5 0 0 1 0-1h1.2c.2 0 .3-.1.3-.3 0-.2-.1-.3-.3-.3H4.4a.4.4 0 1 1 0-.8h1.3c.2 0 .3-.1.3-.3 0-.2-.1-.3-.3-.3H4.5a.5.5 0 1 1 0-1H6c.6 0 1 .4 1 1Z" fill-rule="evenodd"/></svg>',
|
||||
'orientation': '<svg width="24" height="24"><path d="M7.3 6.4 1 13l6.4 6.5 6.5-6.5-6.5-6.5ZM3.7 13l3.6-3.7L11 13l-3.7 3.7-3.6-3.7ZM12 6l2.8 2.7c.3.3.3.8 0 1-.3.4-.9.4-1.2 0L9.2 5.7a.8.8 0 0 1 0-1.2L13.6.2c.3-.3.9-.3 1.2 0 .3.3.3.8 0 1.1L12 4h1a9 9 0 1 1-4.3 16.9l1.5-1.5A7 7 0 1 0 13 6h-1Z" fill-rule="nonzero"/></svg>',
|
||||
'other-actions': '<svg width="24" height="24"><path d="M8.5 13.5c.6 0 1 .2 1.4.6.4.4.6.8.6 1.4V19c0 .6-.2 1-.6 1.4a2 2 0 0 1-1.4.6H5a2 2 0 0 1-1.4-.6A2 2 0 0 1 3 19v-3.5c0-.6.2-1 .6-1.4a2 2 0 0 1 1.4-.6h3.5Zm10.5 0c.6 0 1 .2 1.4.6.4.4.6.8.6 1.4V19c0 .6-.2 1-.6 1.4a2 2 0 0 1-1.4.6h-3.5a2 2 0 0 1-1.4-.6 2 2 0 0 1-.6-1.4v-3.5c0-.6.2-1 .6-1.4a2 2 0 0 1 1.4-.6H19ZM5 19h3.5v-3.5H5V19Zm10.5 0H19v-3.5h-3.5V19Zm1-15.7a.5.5 0 0 1 1 0 5.5 5.5 0 0 0 3.2 3.3.5.5 0 0 1 0 .8l-.3.1a5.5 5.5 0 0 0-3 3.2.5.5 0 0 1-.8 0 5.5 5.5 0 0 0-3-3.2h-.3a.5.5 0 0 1 0-1 5.5 5.5 0 0 0 3.3-3.2Zm-8-.3c.6 0 1 .2 1.4.6.4.4.6.8.6 1.4v3.5c0 .6-.2 1-.6 1.4a2 2 0 0 1-1.4.6H5a2 2 0 0 1-1.4-.6A2 2 0 0 1 3 8.5V5c0-.6.2-1 .6-1.4A2 2 0 0 1 5 3h3.5ZM5 8.5h3.5V5H5v3.5Z"/></svg>',
|
||||
'outdent': '<svg width="24" height="24"><path d="M7 5h12c.6 0 1 .4 1 1s-.4 1-1 1H7a1 1 0 1 1 0-2Zm5 4h7c.6 0 1 .4 1 1s-.4 1-1 1h-7a1 1 0 0 1 0-2Zm0 4h7c.6 0 1 .4 1 1s-.4 1-1 1h-7a1 1 0 0 1 0-2Zm-5 4h12a1 1 0 0 1 0 2H7a1 1 0 0 1 0-2Zm1.6-3.8a1 1 0 0 1-1.2 1.6l-3-2a1 1 0 0 1 0-1.6l3-2a1 1 0 0 1 1.2 1.6L6.8 12l1.8 1.2Z" fill-rule="evenodd"/></svg>',
|
||||
'page-break': '<svg width="24" height="24"><g fill-rule="evenodd"><path d="M5 11c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1 0-2Zm3 0h1c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 0 1 0-2Zm4 0c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1 0-2Zm3 0h1c.6 0 1 .4 1 1s-.4 1-1 1h-1a1 1 0 0 1 0-2Zm4 0c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1 0-2ZM7 3v5h10V3c0-.6.4-1 1-1s1 .4 1 1v7H5V3c0-.6.4-1 1-1s1 .4 1 1ZM6 22a1 1 0 0 1-1-1v-7h14v7c0 .6-.4 1-1 1a1 1 0 0 1-1-1v-5H7v5c0 .6-.4 1-1 1Z"/></g></svg>',
|
||||
'paragraph': '<svg width="24" height="24"><path fill-rule="evenodd" d="M10 5h7a1 1 0 0 1 0 2h-1v11a1 1 0 0 1-2 0V7h-2v11a1 1 0 0 1-2 0v-6c-.5 0-1 0-1.4-.3A3.4 3.4 0 0 1 6.8 10a3.3 3.3 0 0 1 0-2.8 3.4 3.4 0 0 1 1.8-1.8L10 5Z"/></svg>',
|
||||
'paste-column-after': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 1a3 3 0 0 1 2.8 2H18c1 0 2 .8 2 1.9V7h-2V5h-2v1c0 .6-.4 1-1 1H9a1 1 0 0 1-1-1V5H6v13h7v2H6c-1 0-2-.8-2-1.9V5c0-1 .8-2 1.9-2H9.2A3 3 0 0 1 12 1Zm8 7v12h-6V8h6Zm-1.5 1.5h-3v9h3v-9ZM12 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/></svg>',
|
||||
'paste-column-before': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 1a3 3 0 0 1 2.8 2H18c1 0 2 .8 2 1.9V18c0 1-.8 2-1.9 2H11v-2h7V5h-2v1c0 .6-.4 1-1 1H9a1 1 0 0 1-1-1V5H6v2H4V5c0-1 .8-2 1.9-2H9.2A3 3 0 0 1 12 1Zm-2 7v12H4V8h6ZM8.5 9.5h-3v9h3v-9ZM12 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/></svg>',
|
||||
'paste-row-after': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 1a3 3 0 0 1 2.8 2H18c1 0 2 .8 2 1.9V11h-2V5h-2v1c0 .6-.4 1-1 1H9a1 1 0 0 1-1-1V5H6v13h14c0 1-.8 2-1.9 2H6c-1 0-2-.8-2-1.9V5c0-1 .8-2 1.9-2H9.2A3 3 0 0 1 12 1Zm10 11v5H8v-5h14Zm-1.5 1.5h-11v2h11v-2ZM12 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/></svg>',
|
||||
'paste-row-before': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 1a3 3 0 0 1 2.8 2H18c1 0 2 .8 2 1.9V7h-2V5h-2v1c0 .6-.4 1-1 1H9a1 1 0 0 1-1-1V5H6v13h12v-4h2v4c0 1-.8 2-1.9 2H6c-1 0-2-.8-2-1.9V5c0-1 .8-2 1.9-2H9.2A3 3 0 0 1 12 1Zm10 7v5H8V8h14Zm-1.5 1.5h-11v2h11v-2ZM12 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/></svg>',
|
||||
'paste-text': '<svg width="24" height="24"><path d="M18 9V5h-2v1c0 .6-.4 1-1 1H9a1 1 0 0 1-1-1V5H6v13h3V9h9ZM9 20H6a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h3.2A3 3 0 0 1 12 1a3 3 0 0 1 2.8 2H18a2 2 0 0 1 2 2v4h1v12H9v-1Zm1.5-9.5v9h9v-9h-9ZM12 3a1 1 0 0 0-1 1c0 .5.4 1 1 1s1-.5 1-1-.4-1-1-1Zm0 9h6v2h-.5l-.5-1h-1v4h.8v1h-3.6v-1h.8v-4h-1l-.5 1H12v-2Z" fill-rule="nonzero"/></svg>',
|
||||
'paste': '<svg width="24" height="24"><path d="M18 9V5h-2v1c0 .6-.4 1-1 1H9a1 1 0 0 1-1-1V5H6v13h3V9h9ZM9 20H6a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h3.2A3 3 0 0 1 12 1a3 3 0 0 1 2.8 2H18a2 2 0 0 1 2 2v4h1v12H9v-1Zm1.5-9.5v9h9v-9h-9ZM12 3a1 1 0 0 0-1 1c0 .5.4 1 1 1s1-.5 1-1-.4-1-1-1Z" fill-rule="nonzero"/></svg>',
|
||||
'permanent-pen': '<svg width="24" height="24"><path d="M10.5 17.5 8 20H3v-3l3.5-3.5a2 2 0 0 1 0-3L14 3l1 1-7.3 7.3a1 1 0 0 0 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0L20 9l1 1-7.6 7.6a2 2 0 0 1-2.8 0l-.1-.1Z" fill-rule="nonzero"/></svg>',
|
||||
'photo-filter': '<svg height="24" width="24"><path d="M4.4 21c-.3 0-.6-.2-.9-.5-.3-.3-.4-.6-.5-.9L19.6 3c.3 0 .6.3.9.5l.5.9zM3 14.7v-2.8L11.9 3h2.8zM3 7V5c0-.6.2-1 .6-1.4A2 2 0 0 1 5 3h2zm14 14 4-4v2c0 .6-.2 1-.6 1.4a2 2 0 0 1-1.4.6zm-7.7 0L21 9.3v2.8L12 21z"/></svg>',
|
||||
'pin': '<svg width="24" height="24"><path fill-rule="evenodd" d="M18 3a1 1 0 0 1 1 1v3.6a1 1 0 0 1-1 1h-1.6l2.5 6A1 1 0 0 1 18 16h-5v5h-2v-5H6a1 1 0 0 1-1-1.3l2.6-6H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h12ZM7 6.7h2A1 1 0 0 1 10 8l-2.6 6h9.2L14 8a1 1 0 0 1 .9-1.3h2V5H7v1.6Z"/></svg>',
|
||||
'plus': '<svg width="24" height="24"><path d="M12 4c.5 0 1 .4 1 .9V11h6a1 1 0 0 1 .1 2H13v6a1 1 0 0 1-2 .1V13H5a1 1 0 0 1-.1-2H11V5c0-.6.4-1 1-1Z"/></svg>',
|
||||
'preferences': '<svg width="24" height="24"><path d="m20.1 13.5-1.9.2a5.8 5.8 0 0 1-.6 1.5l1.2 1.5c.4.4.3 1 0 1.4l-.7.7a1 1 0 0 1-1.4 0l-1.5-1.2a6.2 6.2 0 0 1-1.5.6l-.2 1.9c0 .5-.5.9-1 .9h-1a1 1 0 0 1-1-.9l-.2-1.9a5.8 5.8 0 0 1-1.5-.6l-1.5 1.2a1 1 0 0 1-1.4 0l-.7-.7a1 1 0 0 1 0-1.4l1.2-1.5a6.2 6.2 0 0 1-.6-1.5l-1.9-.2a1 1 0 0 1-.9-1v-1c0-.5.4-1 .9-1l1.9-.2a5.8 5.8 0 0 1 .6-1.5L5.2 7.3a1 1 0 0 1 0-1.4l.7-.7a1 1 0 0 1 1.4 0l1.5 1.2a6.2 6.2 0 0 1 1.5-.6l.2-1.9c0-.5.5-.9 1-.9h1c.5 0 1 .4 1 .9l.2 1.9a5.8 5.8 0 0 1 1.5.6l1.5-1.2a1 1 0 0 1 1.4 0l.7.7c.3.4.4 1 0 1.4l-1.2 1.5a6.2 6.2 0 0 1 .6 1.5l1.9.2c.5 0 .9.5.9 1v1c0 .5-.4 1-.9 1ZM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" fill-rule="evenodd"/></svg>',
|
||||
'preview': '<svg width="24" height="24"><path d="M3.5 12.5c.5.8 1.1 1.6 1.8 2.3 2 2 4.2 3.2 6.7 3.2s4.7-1.2 6.7-3.2a16.2 16.2 0 0 0 2.1-2.8 15.7 15.7 0 0 0-2.1-2.8c-2-2-4.2-3.2-6.7-3.2a9.3 9.3 0 0 0-6.7 3.2A16.2 16.2 0 0 0 3.2 12c0 .2.2.3.3.5Zm-2.4-1 .7-1.2L4 7.8C6.2 5.4 8.9 4 12 4c3 0 5.8 1.4 8.1 3.8a18.2 18.2 0 0 1 2.8 3.7v1l-.7 1.2-2.1 2.5c-2.3 2.4-5 3.8-8.1 3.8-3 0-5.8-1.4-8.1-3.8a18.2 18.2 0 0 1-2.8-3.7 1 1 0 0 1 0-1Zm12-3.3a2 2 0 1 0 2.7 2.6 4 4 0 1 1-2.6-2.6Z" fill-rule="nonzero"/></svg>',
|
||||
'print': '<svg width="24" height="24"><path d="M18 8H6a3 3 0 0 0-3 3v6h2v3h14v-3h2v-6a3 3 0 0 0-3-3Zm-1 10H7v-4h10v4Zm.5-5c-.8 0-1.5-.7-1.5-1.5s.7-1.5 1.5-1.5 1.5.7 1.5 1.5-.7 1.5-1.5 1.5Zm.5-8H6v2h12V5Z" fill-rule="nonzero"/></svg>',
|
||||
'quote': '<svg width="24" height="24"><path d="M7.5 17h.9c.4 0 .7-.2.9-.6L11 13V8c0-.6-.4-1-1-1H6a1 1 0 0 0-1 1v4c0 .6.4 1 1 1h2l-1.3 2.7a1 1 0 0 0 .8 1.3Zm8 0h.9c.4 0 .7-.2.9-.6L19 13V8c0-.6-.4-1-1-1h-4a1 1 0 0 0-1 1v4c0 .6.4 1 1 1h2l-1.3 2.7a1 1 0 0 0 .8 1.3Z" fill-rule="nonzero"/></svg>',
|
||||
'reasoning': '<svg height="24" width="24"><path d="M14.3 22h-3.6v-2h3.6zM15.3 18.4H9.7v-2h5.6zM12.8 2c1.6 0 3.1.7 4.3 1.9C18.3 5 19 6.7 19 8.4c0 1.4-.5 2.8-1.7 3.9l-.2.2a3 3 0 0 0-.9 1.5 1 1 0 1 1-2-.4c.3-1.2 1-2 1.7-2.8.7-.6 1.1-1.5 1.1-2.4 0-1.2-.5-2.3-1.3-3.1a4.5 4.5 0 0 0-3-1.3h-.2c-1.2 0-2.3.5-3.2 1.3C8.5 6 8 7.3 8 8.4c0 .7.1 1.5 1 2.4.8.7 1.5 1.6 1.7 2.8a1 1 0 0 1-2 .4c0-.6-.4-1.2-1-1.7A5 5 0 0 1 6 8.4c0-1.7.7-3.3 2-4.5A6.6 6.6 0 0 1 12.4 2z"/></svg>',
|
||||
'redo': '<svg width="24" height="24"><path d="M17.6 10H12c-2.8 0-4.4 1.4-4.9 3.5-.4 2 .3 4 1.4 4.6a1 1 0 1 1-1 1.8c-2-1.2-2.9-4.1-2.3-6.8.6-3 3-5.1 6.8-5.1h5.6l-3.3-3.3a1 1 0 1 1 1.4-1.4l5 5a1 1 0 0 1 0 1.4l-5 5a1 1 0 0 1-1.4-1.4l3.3-3.3Z" fill-rule="nonzero"/></svg>',
|
||||
'reload': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="m5 22.1-1.2-4.7v-.2a1 1 0 0 1 1-1l5 .4a1 1 0 1 1-.2 2l-2.2-.2a7.8 7.8 0 0 0 8.4.2 7.5 7.5 0 0 0 3.5-6.4 1 1 0 1 1 2 0 9.5 9.5 0 0 1-4.5 8 9.9 9.9 0 0 1-10.2 0l.4 1.4a1 1 0 1 1-2 .5ZM13.6 7.4c0-.5.5-1 1-.9l2.8.2a8 8 0 0 0-9.5-1 7.5 7.5 0 0 0-3.6 7 1 1 0 0 1-2 0 9.5 9.5 0 0 1 4.5-8.6 10 10 0 0 1 10.9.3l-.3-1a1 1 0 0 1 2-.5l1.1 4.8a1 1 0 0 1-1 1.2l-5-.4a1 1 0 0 1-.9-1Z"/></g></svg>',
|
||||
'remove-formatting': '<svg width="24" height="24"><path d="M13.2 6a1 1 0 0 1 0 .2l-2.6 10a1 1 0 0 1-1 .8h-.2a.8.8 0 0 1-.8-1l2.6-10H8a1 1 0 1 1 0-2h9a1 1 0 0 1 0 2h-3.8ZM5 18h7a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2Zm13 1.5L16.5 18 15 19.5a.7.7 0 0 1-1-1l1.5-1.5-1.5-1.5a.7.7 0 0 1 1-1l1.5 1.5 1.5-1.5a.7.7 0 0 1 1 1L17.5 17l1.5 1.5a.7.7 0 0 1-1 1Z" fill-rule="evenodd"/></svg>',
|
||||
'remove': '<svg width="24" height="24"><path d="M16 7h3a1 1 0 0 1 0 2h-1v9a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3V9H5a1 1 0 1 1 0-2h3V6a3 3 0 0 1 3-3h2a3 3 0 0 1 3 3v1Zm-2 0V6c0-.6-.4-1-1-1h-2a1 1 0 0 0-1 1v1h4Zm2 2H8v9c0 .6.4 1 1 1h6c.6 0 1-.4 1-1V9Zm-7 3a1 1 0 0 1 2 0v4a1 1 0 0 1-2 0v-4Zm4 0a1 1 0 0 1 2 0v4a1 1 0 0 1-2 0v-4Z" fill-rule="nonzero"/></svg>',
|
||||
'resize-handle': '<svg width="10" height="10"><g fill-rule="nonzero"><path d="M8.1 1.1A.5.5 0 1 1 9 2l-7 7A.5.5 0 1 1 1 8l7-7ZM8.1 5.1A.5.5 0 1 1 9 6l-3 3A.5.5 0 1 1 5 8l3-3Z"/></g></svg>',
|
||||
'resize': '<svg width="24" height="24"><path d="M4 5c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3h6c.3 0 .5.1.7.3.2.2.3.4.3.7 0 .3-.1.5-.3.7a1 1 0 0 1-.7.3H7.4L18 16.6V13c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3.3 0 .5.1.7.3.2.2.3.4.3.7v6c0 .3-.1.5-.3.7a1 1 0 0 1-.7.3h-6a1 1 0 0 1-.7-.3 1 1 0 0 1-.3-.7c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3h3.6L6 7.4V11c0 .3-.1.5-.3.7a1 1 0 0 1-.7.3 1 1 0 0 1-.7-.3A1 1 0 0 1 4 11V5Z" fill-rule="evenodd"/></svg>',
|
||||
'restore-draft': '<svg width="24" height="24"><g fill-rule="evenodd"><path d="M17 13c0 .6-.4 1-1 1h-4V8c0-.6.4-1 1-1s1 .4 1 1v4h2c.6 0 1 .4 1 1Z"/><path d="M4.7 10H9a1 1 0 0 1 0 2H3a1 1 0 0 1-1-1V5a1 1 0 1 1 2 0v3l2.5-2.4a9.2 9.2 0 0 1 10.8-1.5A9 9 0 0 1 13.4 21c-2.4.1-4.7-.7-6.5-2.2a1 1 0 1 1 1.3-1.5 7.2 7.2 0 0 0 11.6-3.7 7 7 0 0 0-3.5-7.7A7.2 7.2 0 0 0 8 7L4.7 10Z" fill-rule="nonzero"/></g></svg>',
|
||||
'revert-changes': '<svg height="24" width="24"><path d="m8.9 18.8.4 3.2H13v-2h-2v-2.6a4.6 4.6 0 0 1-1.6-.6 5.9 5.9 0 0 1-1.3-1l-2.5 1-1-1.7 2.2-1.6a4 4 0 0 1-.2-.7 5.9 5.9 0 0 1 0-.8v-.8l.2-.8-2.2-1.6 1-1.7 2.5 1a6 6 0 0 1 1.2-.9l1.4-.6.4-2.6h2l.3 2.6A5.6 5.6 0 0 1 16 8.2l2.5-1 1 1.6-2.2 1.7.2.7v.8a5 5 0 0 1 0 1h2a2.6 2.6 0 0 0 0-.5V11.3l2.6-2-2.8-4.7-3 1.3a8.2 8.2 0 0 0-.5-.4 3.8 3.8 0 0 0-.6-.3L14.8 2H9.3l-.4 3.2a5 5 0 0 0-1.2.7l-3-1.3L2 9.4l2.6 2V12.6l-2.6 2 2.7 4.7 3-1.3a8 8 0 0 0 1.2.7z"/><path d="m14.5 20.3 2.1-2-2.1-2.2 1.4-1.4 2.1 2.1 2.1-2 1.4 1.3-2 2.1 2 2.1-1.4 1.4-2.1-2-2.1 2zM12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/></svg>',
|
||||
'revision-history': '<svg width="24" height="24"><g fill-rule="evenodd"><path d="M17 13c0 .6-.4 1-1 1h-4V8c0-.6.4-1 1-1s1 .4 1 1v4h2c.6 0 1 .4 1 1Z"/><path d="M4.7 10H9a1 1 0 0 1 0 2H3a1 1 0 0 1-1-1V5a1 1 0 1 1 2 0v3l2.5-2.4a9.2 9.2 0 0 1 10.8-1.5A9 9 0 0 1 13.4 21c-2.4.1-4.7-.7-6.5-2.2a1 1 0 1 1 1.3-1.5 7.2 7.2 0 0 0 11.6-3.7 7 7 0 0 0-3.5-7.7A7.2 7.2 0 0 0 8 7L4.7 10Z" fill-rule="nonzero"/></g></svg>',
|
||||
'rotate-left': '<svg width="24" height="24"><path d="M4.7 10H9a1 1 0 0 1 0 2H3a1 1 0 0 1-1-1V5a1 1 0 1 1 2 0v3l2.5-2.4a9.2 9.2 0 0 1 10.8-1.5A9 9 0 0 1 13.4 21c-2.4.1-4.7-.7-6.5-2.2a1 1 0 1 1 1.3-1.5 7.2 7.2 0 0 0 11.6-3.7 7 7 0 0 0-3.5-7.7A7.2 7.2 0 0 0 8 7L4.7 10Z" fill-rule="nonzero"/></svg>',
|
||||
'rotate-right': '<svg width="24" height="24"><path d="M20 8V5a1 1 0 0 1 2 0v6c0 .6-.4 1-1 1h-6a1 1 0 0 1 0-2h4.3L16 7A7.2 7.2 0 0 0 7.7 6a7 7 0 0 0 3 13.1c1.9.1 3.7-.5 5-1.7a1 1 0 0 1 1.4 1.5A9.2 9.2 0 0 1 2.2 14c-.9-3.9 1-8 4.5-9.9 3.5-1.9 8-1.3 10.8 1.5L20 8Z" fill-rule="nonzero"/></svg>',
|
||||
'rtl': '<svg width="24" height="24"><path d="M8 5h8v2h-2v12h-2V7h-2v12H8v-7c-.5 0-1 0-1.4-.3A3.4 3.4 0 0 1 4.8 10a3.3 3.3 0 0 1 0-2.8 3.4 3.4 0 0 1 1.8-1.8L8 5Zm12 11.2a1 1 0 1 1-1 1.6l-3-2a1 1 0 0 1 0-1.6l3-2a1 1 0 1 1 1 1.6L18.4 15l1.8 1.2Z" fill-rule="evenodd"/></svg>',
|
||||
'saturation': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 20.5a6 6 0 1 1-5.9-10.2 6 6 0 1 1 11.8 0A6 6 0 1 1 12 20.5Zm-1.1-7.7a6 6 0 0 0-.2.3 4.3 4.3 0 0 1-2-1.3c.9.1 1.6.5 2.2 1Zm2.4.3a6 6 0 0 0-.2-.3 4.3 4.3 0 0 1 2.2-1 4.3 4.3 0 0 1-2 1.3ZM12.2 15a6.3 6.3 0 0 1-.4 0 4.3 4.3 0 0 0 .2 2.5 4.3 4.3 0 0 0 .2-2.5Zm.9 4.2a6 6 0 0 0 .8-4.5 6 6 0 0 0 3.4-2.8 4.3 4.3 0 0 1-1.3 8.4 4.3 4.3 0 0 1-2.9-1.1Zm-2.2 0a4.3 4.3 0 1 1-4.2-7.3 6 6 0 0 0 3.4 2.8 6 6 0 0 0 .8 4.5Zm-3-9.2a4.3 4.3 0 1 1 8.3 0 6 6 0 0 0-4.2 1.5A6 6 0 0 0 7.8 10Z"/></svg>',
|
||||
'save': '<svg width="24" height="24"><path d="M5 16h14a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2c0-1.1.9-2 2-2Zm0 2v2h14v-2H5Zm10 0h2v2h-2v-2Zm-4-6.4L8.7 9.3a1 1 0 1 0-1.4 1.4l4 4c.4.4 1 .4 1.4 0l4-4a1 1 0 1 0-1.4-1.4L13 11.6V4a1 1 0 0 0-2 0v7.6Z" fill-rule="nonzero"/></svg>',
|
||||
'search': '<svg width="24" height="24"><path d="M16 17.3a8 8 0 1 1 1.4-1.4l4.3 4.4a1 1 0 0 1-1.4 1.4l-4.4-4.3Zm-5-.3a6 6 0 1 0 0-12 6 6 0 0 0 0 12Z" fill-rule="nonzero"/></svg>',
|
||||
'select-all': '<svg width="24" height="24"><path d="M3 5h2V3a2 2 0 0 0-2 2Zm0 8h2v-2H3v2Zm4 8h2v-2H7v2ZM3 9h2V7H3v2Zm10-6h-2v2h2V3Zm6 0v2h2a2 2 0 0 0-2-2ZM5 21v-2H3c0 1.1.9 2 2 2Zm-2-4h2v-2H3v2ZM9 3H7v2h2V3Zm2 18h2v-2h-2v2Zm8-8h2v-2h-2v2Zm0 8a2 2 0 0 0 2-2h-2v2Zm0-12h2V7h-2v2Zm0 8h2v-2h-2v2Zm-4 4h2v-2h-2v2Zm0-16h2V3h-2v2ZM7 17h10V7H7v10Zm2-8h6v6H9V9Z" fill-rule="nonzero"/></svg>',
|
||||
'selected': '<svg width="24" height="24"><path fill-rule="nonzero" d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2Zm3.6 10.9L7 12.3a.7.7 0 0 0-1 1L9.6 17 18 8.6a.7.7 0 0 0 0-1 .7.7 0 0 0-1 0l-7.4 7.3Z"/></svg>',
|
||||
'send': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="m13.3 22 7-18.3-18.3 7L9 15l4.3 7ZM18 6.8l-.7-.7L9.4 14l.7.7L18 6.8Z"/></svg>',
|
||||
'settings': '<svg width="24" height="24"><path d="M11 6h8c.6 0 1 .4 1 1s-.4 1-1 1h-8v.3c0 .2 0 .3-.2.5l-.6.2H7.8c-.3 0-.4 0-.6-.2a.7.7 0 0 1-.2-.6V8H5a1 1 0 1 1 0-2h2v-.3c0-.2 0-.3.2-.5l.5-.2h2.5c.3 0 .4 0 .6.2l.2.5V6ZM8 8h2V6H8v2Zm9 2.8v.2h2c.6 0 1 .4 1 1s-.4 1-1 1h-2v.3c0 .2 0 .3-.2.5l-.6.2h-2.4c-.3 0-.4 0-.6-.2a.7.7 0 0 1-.2-.6V13H5a1 1 0 0 1 0-2h8v-.3c0-.2 0-.3.2-.5l.6-.2h2.4c.3 0 .4 0 .6.2l.2.6ZM14 13h2v-2h-2v2Zm-3 2.8v.2h8c.6 0 1 .4 1 1s-.4 1-1 1h-8v.3c0 .2 0 .3-.2.5l-.6.2H7.8c-.3 0-.4 0-.6-.2a.7.7 0 0 1-.2-.6V18H5a1 1 0 0 1 0-2h2v-.3c0-.2 0-.3.2-.5l.5-.2h2.5c.3 0 .4 0 .6.2l.2.6ZM8 18h2v-2H8v2Z" fill-rule="evenodd"/></svg>',
|
||||
'sharpen': '<svg width="24" height="24"><path fill-rule="evenodd" d="m16 6 4 4-8 9-8-9 4-4h8Zm-4 10.2 5.5-6.2-.1-.1H12v-.3h5.1l-.2-.2H12V9h4.6l-.2-.2H12v-.3h4.1l-.2-.2H12V8h3.6l-.2-.2H8.7L6.5 10l.1.1H12v.3H6.9l.2.2H12v.3H7.3l.2.2H12v.3H7.7l.3.2h4v.3H8.2l.2.2H12v.3H8.6l.3.2H12v.3H9l.3.2H12v.3H9.5l.2.2H12v.3h-2l.2.2H12v.3h-1.6l.2.2H12v.3h-1.1l.2.2h.9v.3h-.7l.2.2h.5v.3h-.3l.3.2Z"/></svg>',
|
||||
'source-close': '<svg width="16" height="16"><path fill-rule="evenodd" d="M11.8 5 9 8l3 3a.6.6 0 0 1-1 .8L8 9l-3 3a.6.6 0 0 1-.8-1L7 8l-3-3a.6.6 0 0 1 1-.8L8 7l3-3a.6.6 0 0 1 .8 1Z"/></svg>',
|
||||
'source-file': '<svg width="16" height="16"><path fill-rule="evenodd" d="M4.3 2H10L13 5v7.7c0 .7-.6 1.3-1.3 1.3H4.3A1.3 1.3 0 0 1 3 12.7V3.3C3 2.6 3.6 2 4.3 2Zm0 10.7h7.4V6H9V3.3H4.3v9.4Z"/></svg>',
|
||||
'source-image': '<svg height="16" width="16"><path d="M2.7 2h10.6c.4 0 .7.3.7.7v10.6a.7.7 0 0 1-.7.7H2.7a.7.7 0 0 1-.7-.7V2.7c0-.4.3-.7.7-.7zm.6 8.5 2.2-2.2a.5.5 0 0 1 .7 0L8 10l2.8-2.7a.5.5 0 0 1 .7 0l1.3 1.3V3.3H3.3v7.2zm0 1.4v.8h2l2-2-1.4-1.3zM11 8.4l1.7 1.6v2.7h-6L11 8.4z" fill-rule="evenodd"/><path d="M8.7 6.3a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/></svg>',
|
||||
'source-link': '<svg height="16" width="16"><g fill-rule="evenodd"><path d="M3.5 12.6a2.7 2.7 0 0 1 0-3.8l.5-.6a.7.7 0 1 1 1 1l-.6.5a1.3 1.3 0 1 0 1.9 2L7.9 10a2.1 2.1 0 0 0 .6-2 .7.7 0 0 1 1.3-.3 3.4 3.4 0 0 1-1 3.2l-1.6 1.6a2.7 2.7 0 0 1-3.8 0z"/><path d="M12.6 3.5a2.7 2.7 0 0 1 0 3.7l-.6.6a.7.7 0 1 1-1-1l.6-.5a1.3 1.3 0 1 0-1.9-2L8.1 6a2.1 2.1 0 0 0-.6 2 .7.7 0 1 1-1.3.3 3.4 3.4 0 0 1 1-3.2l1.6-1.7a2.7 2.7 0 0 1 3.8 0z"/></g></svg>',
|
||||
'source-selection': '<svg width="16" height="16"><path d="M3.2 12.8h.7V14H2.6a.6.6 0 0 1-.6-.6v-1.3h1.2v.7ZM7.5 14H4.9v-1.2h2.6V14Zm5.9-4.8a.6.6 0 1 1 0 1.2h-2.2l2.6 2.6a.6.6 0 1 1-.8.8l-2.6-2.6v2.2a.6.6 0 1 1-1.2 0V9.2h4.2ZM3.2 11.1H2V8.5h1.2v2.6Zm0-3.6H2V4.9h1.2v2.6Zm10.8 0h-1.2V4.9H14v2.6ZM3.9 3.2h-.7v.7H2V2.6a.6.6 0 0 1 .6-.6h1.3v1.2ZM13.4 2a.6.6 0 0 1 .6.6v1.3h-1.2v-.7h-.7V2h1.3Zm-6 1.2H5V2h2.6v1.2Zm3.7 0H8.5V2h2.6v1.2Z"/></svg>',
|
||||
'sourcecode': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="M9.8 15.7c.3.3.3.8 0 1-.3.4-.9.4-1.2 0l-4.4-4.1a.8.8 0 0 1 0-1.2l4.4-4.2c.3-.3.9-.3 1.2 0 .3.3.3.8 0 1.1L6 12l3.8 3.7ZM14.2 15.7c-.3.3-.3.8 0 1 .4.4.9.4 1.2 0l4.4-4.1c.3-.3.3-.9 0-1.2l-4.4-4.2a.8.8 0 0 0-1.2 0c-.3.3-.3.8 0 1.1L18 12l-3.8 3.7Z"/></g></svg>',
|
||||
'spell-check': '<svg width="24" height="24"><path d="M6 8v3H5V5c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3h2c.3 0 .5.1.7.3.2.2.3.4.3.7v6H8V8H6Zm0-3v2h2V5H6Zm13 0h-3v5h3v1h-3a1 1 0 0 1-.7-.3 1 1 0 0 1-.3-.7V5c0-.3.1-.5.3-.7.2-.2.4-.3.7-.3h3v1Zm-5 1.5-.1.7c-.1.2-.3.3-.6.3.3 0 .5.1.6.3l.1.7V10c0 .3-.1.5-.3.7a1 1 0 0 1-.7.3h-3V4h3c.3 0 .5.1.7.3.2.2.3.4.3.7v1.5ZM13 10V8h-2v2h2Zm0-3V5h-2v2h2Zm3 5 1 1-6.5 7L7 15.5l1.3-1 2.2 2.2L16 12Z" fill-rule="evenodd"/></svg>',
|
||||
'stop': '<svg height="24" width="24"><path d="M19 17a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
|
||||
'strike-through': '<svg width="24" height="24"><g fill-rule="evenodd"><path d="M15.6 8.5c-.5-.7-1-1.1-1.3-1.3-.6-.4-1.3-.6-2-.6-2.7 0-2.8 1.7-2.8 2.1 0 1.6 1.8 2 3.2 2.3 4.4.9 4.6 2.8 4.6 3.9 0 1.4-.7 4.1-5 4.1A6.2 6.2 0 0 1 7 16.4l1.5-1.1c.4.6 1.6 2 3.7 2 1.6 0 2.5-.4 3-1.2.4-.8.3-2-.8-2.6-.7-.4-1.6-.7-2.9-1-1-.2-3.9-.8-3.9-3.6C7.6 6 10.3 5 12.4 5c2.9 0 4.2 1.6 4.7 2.4l-1.5 1.1Z"/><path d="M5 11h14a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2Z" fill-rule="nonzero"/></g></svg>',
|
||||
'subscript': '<svg width="24" height="24"><path d="m10.4 10 4.6 4.6-1.4 1.4L9 11.4 4.4 16 3 14.6 7.6 10 3 5.4 4.4 4 9 8.6 13.6 4 15 5.4 10.4 10ZM21 19h-5v-1l1-.8 1.7-1.6c.3-.4.5-.8.5-1.2 0-.3 0-.6-.2-.7-.2-.2-.5-.3-.9-.3a2 2 0 0 0-.8.2l-.7.3-.4-1.1 1-.6 1.2-.2c.8 0 1.4.3 1.8.7.4.4.6.9.6 1.5s-.2 1.1-.5 1.6a8 8 0 0 1-1.3 1.3l-.6.6h2.6V19Z" fill-rule="nonzero"/></svg>',
|
||||
'suggestededits-badge': '<svg height="24" width="24"><path d="M11 3H7a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h10a2 2 0 0 0 2-2V9.7a6 6 0 0 1-2 .3v9H7V5h4a6 6 0 0 1 0-2z"/><path d="M15 13v-2h-4v2zm-3 2H9v2h3zm-1-8H9v2h2zm4 10v-2h-2v2z"/><path d="M21 4a4 4 0 1 1-8 0 4 4 0 0 1 8 0z" class="tox-icon--badge"/></svg>',
|
||||
'suggestededits': '<svg width="24" height="24"><path d="M7 3h7.4L19 7.6V19a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2Zm0 16h10V9h-3V5H7v14Z" clip-rule="evenodd"/><path d="M9 7h2v2H9V7Zm2 4h4v2h-4v-2Zm-2 4h3v2H9v-2Z"/><path d="M9 7h2v2H9V7Zm4 8h2v2h-2v-2Z"/></svg>',
|
||||
'summarize': '<svg width="24" height="24"><path fill-rule="evenodd" d="M14 3c.3 0 .6 0 .8.2-1.3 1-1 3.3.7 4h.2l.3.2A2 2 0 0 1 14 9h-4a2 2 0 0 1-2-2H6v13h12V9.8a2.4 2.4 0 0 0 2 0V21a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h3a2 2 0 0 1 2-2h4Zm-4 4h4V5h-4v2Z"/><path d="M15 16a1 1 0 1 1 0 2H9a1 1 0 1 1 0-2h6Zm0-4a1 1 0 1 1 0 2H9a1 1 0 1 1 0-2h6Zm3.7-9.8a.3.3 0 0 1 .6 0 4.1 4.1 0 0 0 2.5 2.5.3.3 0 0 1 0 .6l-.2.1a4.1 4.1 0 0 0-2.1 2v.1l-.2.3V8a.3.3 0 0 1-.1 0H19a.3.3 0 0 1 0 .1.4.4 0 0 1-.2 0 .3.3 0 0 1-.1 0v-.1a.3.3 0 0 1-.1 0v-.1a4.1 4.1 0 0 0-2.3-2.4h-.2a.3.3 0 0 1 0-.7 4.1 4.1 0 0 0 2.5-2.5Z"/></svg>',
|
||||
'superscript': '<svg width="24" height="24"><path d="M15 9.4 10.4 14l4.6 4.6-1.4 1.4L9 15.4 4.4 20 3 18.6 7.6 14 3 9.4 4.4 8 9 12.6 13.6 8 15 9.4Zm5.9 1.6h-5v-1l1-.8 1.7-1.6c.3-.5.5-.9.5-1.3 0-.3 0-.5-.2-.7-.2-.2-.5-.3-.9-.3l-.8.2-.7.4-.4-1.2c.2-.2.5-.4 1-.5.3-.2.8-.2 1.2-.2.8 0 1.4.2 1.8.6.4.4.6 1 .6 1.6 0 .5-.2 1-.5 1.5l-1.3 1.4-.6.5h2.6V11Z" fill-rule="nonzero"/></svg>',
|
||||
'table-caption': '<svg width="24" height="24"><g fill-rule="nonzero"><rect width="12" height="2" x="3" y="4" rx="1"/><path d="M19 8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-8c0-1.1.9-2 2-2h14ZM5 15v3h6v-3H5Zm14 0h-6v3h6v-3Zm0-5h-6v3h6v-3ZM5 13h6v-3H5v3Z"/></g></svg>',
|
||||
'table-cell-classes': '<svg width="24" height="24"><g fill-rule="evenodd"><path fill-rule="nonzero" d="M13 4v9H3V6c0-1.1.9-2 2-2h8Zm-2 2H5v5h6V6Z"/><path fill-rule="nonzero" d="M13 4h6a2 2 0 0 1 2 2v7h-8v-2h6V6h-6V4Z" opacity=".2"/><path d="m18 20-2.6 1.6.7-3-2.4-2 3.1-.2 1.2-2.9 1.2 2.9 3.1.2-2.4 2 .7 3z"/><path fill-rule="nonzero" d="M3 13v5c0 1.1.9 2 2 2h8v-7h-2v5H5v-5H3Z" opacity=".2"/></g></svg>',
|
||||
'table-cell-properties': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm-8 9H5v5h6v-5Zm8 0h-6v5h6v-5Zm-8-7H5v5h6V6Z"/></svg>',
|
||||
'table-cell-select-all': '<svg width="24" height="24"><g fill-rule="evenodd"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm0 2H5v12h14V6Z"/><path d="M13 6v5h6v2h-6v5h-2v-5H5v-2h6V6h2Z" opacity=".2"/></g></svg>',
|
||||
'table-cell-select-inner': '<svg width="24" height="24"><g fill-rule="evenodd"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm0 2H5v12h14V6Z" opacity=".2"/><path d="M13 6v5h6v2h-6v5h-2v-5H5v-2h6V6h2Z"/></g></svg>',
|
||||
'table-classes': '<svg width="24" height="24"><g fill-rule="evenodd"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v7h-8v7H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm-8 9H5v5h6v-5Zm8-7h-6v5h6V6Zm-8 0H5v5h6V6Z"/><path d="m18 20-2.6 1.6.7-3-2.4-2 3.1-.2 1.2-2.9 1.2 2.9 3.1.2-2.4 2 .7 3z"/></g></svg>',
|
||||
'table-delete-column': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm-4 4h-2V6h-2v2H9V6H5v12h4v-2h2v2h2v-2h2v2h4V6h-4v2Zm.3.5 1 1.2-3 2.3 3 2.3-1 1.2L12 13l-3.3 2.6-1-1.2 3-2.3-3-2.3 1-1.2L12 11l3.3-2.5Z"/></svg>',
|
||||
'table-delete-row': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm0 2H5v3h2.5v2H5v2h2.5v2H5v3h14v-3h-2.5v-2H19v-2h-2.5V9H19V6Zm-4.7 1.8 1.2 1L13 12l2.6 3.3-1.2 1-2.3-3-2.3 3-1.2-1L11 12 8.5 8.7l1.2-1 2.3 3 2.3-3Z"/></svg>',
|
||||
'table-delete-table': '<svg width="24" height="24"><g fill-rule="nonzero"><path d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14ZM5 6v12h14V6H5Z"/><path d="m14.4 8.6 1.1 1-2.4 2.4 2.4 2.4-1.1 1.1-2.4-2.4-2.4 2.4-1-1.1 2.3-2.4-2.3-2.4 1-1 2.4 2.3z"/></g></svg>',
|
||||
'table-insert-column-after': '<svg width="24" height="24"><path fill-rule="nonzero" d="M20 4c.6 0 1 .4 1 1v2a1 1 0 0 1-2 0V6h-8v12h8v-1a1 1 0 0 1 2 0v2c0 .5-.4 1-.9 1H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h15ZM9 13H5v5h4v-5Zm7-5c.5 0 1 .4 1 .9V11h2a1 1 0 0 1 .1 2H17v2a1 1 0 0 1-2 .1V13h-2a1 1 0 0 1-.1-2H15V9c0-.6.4-1 1-1ZM9 6H5v5h4V6Z"/></svg>',
|
||||
'table-insert-column-before': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a1 1 0 0 1-1-1v-2a1 1 0 0 1 2 0v1h8V6H5v1a1 1 0 1 1-2 0V5c0-.6.4-1 1-1h15Zm0 9h-4v5h4v-5ZM8 8c.5 0 1 .4 1 .9V11h2a1 1 0 0 1 .1 2H9v2a1 1 0 0 1-2 .1V13H5a1 1 0 0 1-.1-2H7V9c0-.6.4-1 1-1Zm11-2h-4v5h4V6Z"/></svg>',
|
||||
'table-insert-row-above': '<svg width="24" height="24"><path fill-rule="nonzero" d="M6 4a1 1 0 1 1 0 2H5v6h14V6h-1a1 1 0 0 1 0-2h2c.6 0 1 .4 1 1v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5c0-.6.4-1 1-1h2Zm5 10H5v4h6v-4Zm8 0h-6v4h6v-4ZM12 3c.5 0 1 .4 1 .9V6h2a1 1 0 0 1 0 2h-2v2a1 1 0 0 1-2 .1V8H9a1 1 0 0 1 0-2h2V4c0-.6.4-1 1-1Z"/></svg>',
|
||||
'table-insert-row-after': '<svg width="24" height="24"><path fill-rule="nonzero" d="M12 13c.5 0 1 .4 1 .9V16h2a1 1 0 0 1 .1 2H13v2a1 1 0 0 1-2 .1V18H9a1 1 0 0 1-.1-2H11v-2c0-.6.4-1 1-1Zm6 7a1 1 0 0 1 0-2h1v-6H5v6h1a1 1 0 0 1 0 2H4a1 1 0 0 1-1-1V6c0-1.1.9-2 2-2h14a2 2 0 0 1 2 2v13c0 .5-.4 1-.9 1H18ZM11 6H5v4h6V6Zm8 0h-6v4h6V6Z"/></svg>',
|
||||
'table-left-header': '<svg width="24" height="24"><path d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm0 9h-4v5h4v-5Zm-6 0H9v5h4v-5Zm0-7H9v5h4V6Zm6 0h-4v5h4V6Z"/></svg>',
|
||||
'table-merge-cells': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14ZM5 15.5V18h3v-2.5H5Zm14-5h-9V18h9v-7.5ZM19 6h-4v2.5h4V6ZM8 6H5v2.5h3V6Zm5 0h-3v2.5h3V6Zm-8 7.5h3v-3H5v3Z"/></svg>',
|
||||
'table-row-numbering-rtl': '<svg width="24" height="24"><path d="M6 4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H6Zm0 12h8v3H6v-3Zm11 0c.6 0 1 .4 1 1v1a1 1 0 0 1-2 0v-1c0-.6.4-1 1-1ZM6 11h8v3H6v-3Zm11 0c.6 0 1 .4 1 1v1a1 1 0 0 1-2 0v-1c0-.6.4-1 1-1ZM6 6h8v3H6V6Zm11 0c.6 0 1 .4 1 1v1a1 1 0 1 1-2 0V7c0-.6.4-1 1-1Z"/></svg>',
|
||||
'table-row-numbering': '<svg width="24" height="24"><path d="M18 4a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h12Zm0 12h-8v3h8v-3ZM7 16a1 1 0 0 0-1 1v1a1 1 0 0 0 2 0v-1c0-.6-.4-1-1-1Zm11-5h-8v3h8v-3ZM7 11a1 1 0 0 0-1 1v1a1 1 0 0 0 2 0v-1c0-.6-.4-1-1-1Zm11-5h-8v3h8V6ZM7 6a1 1 0 0 0-1 1v1a1 1 0 1 0 2 0V7c0-.6-.4-1-1-1Z"/></svg>',
|
||||
'table-row-properties': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14ZM5 15v3h6v-3H5Zm14 0h-6v3h6v-3Zm0-9h-6v3h6V6ZM5 9h6V6H5v3Z"/></svg>',
|
||||
'table-split-cells': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14ZM8 15.5H5V18h3v-2.5Zm11-5h-9V18h9v-7.5Zm-2.5 1 1 1-2 2 2 2-1 1-2-2-2 2-1-1 2-2-2-2 1-1 2 2 2-2Zm-8.5-1H5v3h3v-3ZM19 6h-4v2.5h4V6ZM8 6H5v2.5h3V6Zm5 0h-3v2.5h3V6Z"/></svg>',
|
||||
'table-top-header': '<svg width="24" height="24"><path d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14Zm-8 11H5v3h6v-3Zm8 0h-6v3h6v-3Zm0-5h-6v3h6v-3ZM5 13h6v-3H5v3Z"/></svg>',
|
||||
'table': '<svg width="24" height="24"><path fill-rule="nonzero" d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h14ZM5 14v4h6v-4H5Zm14 0h-6v4h6v-4Zm0-6h-6v4h6V8ZM5 12h6V8H5v4Z"/></svg>',
|
||||
'template-add': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M9 12v4H5a2 2 0 0 0-2 2v3h9.3a6 6 0 0 1-.3-2H5v-1h7a6 6 0 0 1 .8-2H11v-5l-.8-.6a3 3 0 1 1 3.6 0l-.8.6v4.7a6 6 0 0 1 2-1.9V12a5 5 0 1 0-6 0Z"/><path d="M18 15c.5 0 1 .4 1 .9V18h2a1 1 0 0 1 .1 2H19v2a1 1 0 0 1-2 .1V20h-2a1 1 0 0 1-.1-2H17v-2c0-.6.4-1 1-1Z"/></svg>',
|
||||
'template': '<svg width="24" height="24"><path d="M19 19v-1H5v1h14ZM9 16v-4a5 5 0 1 1 6 0v4h4a2 2 0 0 1 2 2v3H3v-3c0-1.1.9-2 2-2h4Zm4 0v-5l.8-.6a3 3 0 1 0-3.6 0l.8.6v5h2Z" fill-rule="nonzero"/></svg>',
|
||||
'temporary-placeholder': '<svg width="24" height="24"><g fill-rule="evenodd"><path d="M9 7.6V6h2.5V4.5a.5.5 0 1 1 1 0V6H15v1.6a8 8 0 1 1-6 0Zm-2.6 5.3a.5.5 0 0 0 .3.6c.3 0 .6 0 .6-.3l.1-.2a5 5 0 0 1 3.3-2.8c.3-.1.4-.4.4-.6-.1-.3-.4-.5-.6-.4a6 6 0 0 0-4.1 3.7Z"/><circle cx="14" cy="4" r="1"/><circle cx="12" cy="2" r="1"/><circle cx="10" cy="4" r="1"/></g></svg>',
|
||||
'text-color': '<svg width="24" height="24"><g fill-rule="evenodd"><path class="tox-icon-text-color__color" d="M3 18h18v3H3z"/><path d="M8.7 16h-.8a.5.5 0 0 1-.5-.6l2.7-9c.1-.3.3-.4.5-.4h2.8c.2 0 .4.1.5.4l2.7 9a.5.5 0 0 1-.5.6h-.8a.5.5 0 0 1-.4-.4l-.7-2.2c0-.3-.3-.4-.5-.4h-3.4c-.2 0-.4.1-.5.4l-.7 2.2c0 .3-.2.4-.4.4Zm2.6-7.6-.6 2a.5.5 0 0 0 .5.6h1.6a.5.5 0 0 0 .5-.6l-.6-2c0-.3-.3-.4-.5-.4h-.4c-.2 0-.4.1-.5.4Z"/></g></svg>',
|
||||
'text-size-decrease': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M14 5a1 1 0 1 1 0 2h-4v11a1 1 0 1 1-2 0V7H4a1 1 0 0 1 0-2h10ZM14 12a1 1 0 1 0 0 2h6a1 1 0 1 0 0-2h-6Z"/></svg>',
|
||||
'text-size-increase': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M14 5a1 1 0 1 1 0 2h-4v11a1 1 0 1 1-2 0V7H4a1 1 0 0 1 0-2h10ZM17 9a1 1 0 0 0-1 1v2h-2a1 1 0 1 0 0 2h2v2a1 1 0 1 0 2 0v-2h2a1 1 0 1 0 0-2h-2v-2c0-.6-.4-1-1-1Z"/></svg>',
|
||||
'toc': '<svg width="24" height="24"><path d="M5 5c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 1 1 0-2Zm3 0h11c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 1 1 0-2Zm-3 8c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1 0-2Zm3 0h11c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 0 1 0-2Zm0-4c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 1 1 0-2Zm3 0h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm-3 8c.6 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1 0-2Zm3 0h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',
|
||||
'transform-image': '<svg height="24" width="24"><path d="M3 21v-6h2V9H3V3h6v2h6V3h6v6h-2v6h2v6h-6v-2H9v2zm6-4h6v-2h2V9h-2V7H9v2H7v6h2zM5 7h2V5H5zm12 0h2V5h-2zm0 12h2v-2h-2zM5 19h2v-2H5z"/></svg>',
|
||||
'translate': '<svg width="24" height="24"><path d="m12.7 14.3-.3.7-.4.7-2.2-2.2-3.1 3c-.3.4-.8.4-1 0a.7.7 0 0 1 0-1l3.1-3A12.4 12.4 0 0 1 6.7 9H8a10.1 10.1 0 0 0 1.7 2.4c.5-.5 1-1.1 1.4-1.8l.9-2H4.7a.7.7 0 1 1 0-1.5h4.4v-.7c0-.4.3-.8.7-.8.4 0 .7.4.7.8v.7H15c.4 0 .8.3.8.7 0 .4-.4.8-.8.8h-1.4a12.3 12.3 0 0 1-1 2.4 13.5 13.5 0 0 1-1.7 2.3l1.9 1.8Zm4.3-3 2.7 7.3a.5.5 0 0 1-.4.7 1 1 0 0 1-1-.7l-.6-1.5h-3.4l-.6 1.5a1 1 0 0 1-1 .7.5.5 0 0 1-.4-.7l2.7-7.4a1 1 0 0 1 2 0Zm-2.2 4.4h2.4L16 12.5l-1.2 3.2Z" fill-rule="evenodd"/></svg>',
|
||||
'typography': '<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M17 5a1 1 0 1 1 0 2h-4v11a1 1 0 1 1-2 0V7H7a1 1 0 0 1 0-2h10Z"/><path d="m17.5 14 .8-1.7 1.7-.8-1.7-.8-.8-1.7-.8 1.7-1.7.8 1.7.8.8 1.7ZM7 14l1 2 2 1-2 1-1 2-1-2-2-1 2-1 1-2Z"/></svg>',
|
||||
'underline': '<svg width="24" height="24"><path d="M16 5c.6 0 1 .4 1 1v5.5a4 4 0 0 1-.4 1.8l-1 1.4a5.3 5.3 0 0 1-5.5 1 5 5 0 0 1-1.6-1c-.5-.4-.8-.9-1.1-1.4a4 4 0 0 1-.4-1.8V6c0-.6.4-1 1-1s1 .4 1 1v5.5c0 .3 0 .6.2 1l.6.7a3.3 3.3 0 0 0 2.2.8 3.4 3.4 0 0 0 2.2-.8c.3-.2.4-.5.6-.8l.2-.9V6c0-.6.4-1 1-1ZM8 17h8c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',
|
||||
'undo': '<svg width="24" height="24"><path d="M6.4 8H12c3.7 0 6.2 2 6.8 5.1.6 2.7-.4 5.6-2.3 6.8a1 1 0 0 1-1-1.8c1.1-.6 1.8-2.7 1.4-4.6-.5-2.1-2.1-3.5-4.9-3.5H6.4l3.3 3.3a1 1 0 1 1-1.4 1.4l-5-5a1 1 0 0 1 0-1.4l5-5a1 1 0 0 1 1.4 1.4L6.4 8Z" fill-rule="nonzero"/></svg>',
|
||||
'unlink': '<svg width="24" height="24"><path d="M6.2 12.3a1 1 0 0 1 1.4 1.4l-2 2a2 2 0 1 0 2.6 2.8l4.8-4.8a1 1 0 0 0 0-1.4 1 1 0 1 1 1.4-1.3 2.9 2.9 0 0 1 0 4L9.6 20a3.9 3.9 0 0 1-5.5-5.5l2-2Zm11.6-.6a1 1 0 0 1-1.4-1.4l2.1-2a2 2 0 1 0-2.7-2.8L11 10.3a1 1 0 0 0 0 1.4A1 1 0 1 1 9.6 13a2.9 2.9 0 0 1 0-4L14.4 4a3.9 3.9 0 0 1 5.5 5.5l-2 2ZM7.6 6.3a.8.8 0 0 1-1 1.1L3.3 4.2a.7.7 0 1 1 1-1l3.2 3.1ZM5.1 8.6a.8.8 0 0 1 0 1.5H3a.8.8 0 0 1 0-1.5H5Zm5-3.5a.8.8 0 0 1-1.5 0V3a.8.8 0 0 1 1.5 0V5Zm6 11.8a.8.8 0 0 1 1-1l3.2 3.2a.8.8 0 0 1-1 1L16 17Zm-2.2 2a.8.8 0 0 1 1.5 0V21a.8.8 0 0 1-1.5 0V19Zm5-3.5a.7.7 0 1 1 0-1.5H21a.8.8 0 0 1 0 1.5H19Z" fill-rule="nonzero"/></svg>',
|
||||
'unlock': '<svg width="24" height="24"><path d="M16 5c.8 0 1.5.3 2.1.9.6.6.9 1.3.9 2.1v3h-2V8a1 1 0 0 0-.3-.7A1 1 0 0 0 16 7h-2a1 1 0 0 0-.7.3 1 1 0 0 0-.3.7v3h.3c.2 0 .3 0 .5.2l.2.6v7.4c0 .3 0 .4-.2.6l-.6.2H4.8c-.3 0-.4 0-.6-.2a.7.7 0 0 1-.2-.6v-7.4c0-.3 0-.4.2-.6l.5-.2H11V8c0-.8.3-1.5.9-2.1.6-.6 1.3-.9 2.1-.9h2Z" fill-rule="evenodd"/></svg>',
|
||||
'unordered-list': '<svg width="24" height="24"><path d="M11 5h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0 6h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0 6h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2ZM4.5 6c0-.4.1-.8.4-1 .3-.4.7-.5 1.1-.5.4 0 .8.1 1 .4.4.3.5.7.5 1.1 0 .4-.1.8-.4 1-.3.4-.7.5-1.1.5-.4 0-.8-.1-1-.4-.4-.3-.5-.7-.5-1.1Zm0 6c0-.4.1-.8.4-1 .3-.4.7-.5 1.1-.5.4 0 .8.1 1 .4.4.3.5.7.5 1.1 0 .4-.1.8-.4 1-.3.4-.7.5-1.1.5-.4 0-.8-.1-1-.4-.4-.3-.5-.7-.5-1.1Zm0 6c0-.4.1-.8.4-1 .3-.4.7-.5 1.1-.5.4 0 .8.1 1 .4.4.3.5.7.5 1.1 0 .4-.1.8-.4 1-.3.4-.7.5-1.1.5-.4 0-.8-.1-1-.4-.4-.3-.5-.7-.5-1.1Z" fill-rule="evenodd"/></svg>',
|
||||
'unpin': '<svg width="24" height="24"><path fill-rule="evenodd" d="M21 19.6 19.7 21l-5-5H13v5h-2v-5H6a1 1 0 0 1-1-1.4l2.4-5.8-.1-.2H6a1 1 0 0 1-1-1V6.5l-2-2L4.4 3 21 19.6ZM7.5 14h5.1L9 10.4 7.5 14Z"/><path d="M18 3a1 1 0 0 1 1 1v3.6a1 1 0 0 1-1 1h-1.5l2.4 6a1 1 0 0 1 0 .2L15.4 11l-1.2-3a1 1 0 0 1 .9-1.4h2V5H9.2l-2-2H18Z"/></svg>',
|
||||
'unselected': '<svg width="24" height="24"><path fill-rule="nonzero" d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2Zm0 1a1 1 0 0 0-1 1v12c0 .6.4 1 1 1h12c.6 0 1-.4 1-1V6c0-.6-.4-1-1-1H6Z"/></svg>',
|
||||
'upload-from-device': '<svg width="24" height="24"><path d="M11 18.7c0 .6.4 1 1 1s1-.4 1-1v-3l1.5.8a1 1 0 0 0 1.3-.2 1 1 0 0 0-.3-1.4L12 12.6 8.5 15a1 1 0 0 0-.3 1.4c.3.4.9.5 1.3.2l1.5-.9v3.1Z"/><path d="M9.6 4a1 1 0 0 1 .8.4l1.3 2h6.7A2.6 2.6 0 0 1 21 8.8v8.2a2.6 2.6 0 0 1-2.6 2.6H16v-2h2.4a.6.6 0 0 0 .6-.6V9a.6.6 0 0 0-.6-.6h-7.2a1 1 0 0 1-.8-.4L9 5.9H5.6a.6.6 0 0 0-.6.6v10.6a.6.6 0 0 0 .6.6H8v2H5.6A2.6 2.6 0 0 1 3 17.1V6.5A2.6 2.6 0 0 1 5.6 4h4Z"/></svg>',
|
||||
'upload-from-link': '<svg height="24" width="24"><path d="M11.9 5.1a4 4 0 0 1 5.5 5.6l-.8.8a1 1 0 0 1-1.4-1.4l.8-.8a2 2 0 0 0-2.7-2.7l-2.4 2.3a3 3 0 0 0-.8 3 1 1 0 0 1-2 .4 5 5 0 0 1 1.4-4.8zm8.7 10.5a1 1 0 0 1-1.2 1.5l-1.4-1V19a1 1 0 0 1-2 0v-3l-1.4 1.1a1 1 0 0 1-1.2-1.5l3.6-2.9 3.6 2.9z"/><path d="M9.7 18.9A4 4 0 0 1 4 13.3l.8-.8A1 1 0 0 1 6.3 14l-.7.8a2 2 0 0 0 0 2.7c.7.8 2.1.9 2.9.1L11 15c.8-.8.7-1.8.5-2.8a1 1 0 0 1 1.9-.5 5 5 0 0 1-1.3 4.8z"/></svg>',
|
||||
'upload': '<svg width="24" height="24"><path d="M18 19v-2a1 1 0 0 1 2 0v3c0 .6-.4 1-1 1H5a1 1 0 0 1-1-1v-3a1 1 0 0 1 2 0v2h12ZM11 6.4 8.7 8.7a1 1 0 0 1-1.4-1.4l4-4a1 1 0 0 1 1.4 0l4 4a1 1 0 1 1-1.4 1.4L13 6.4V16a1 1 0 0 1-2 0V6.4Z" fill-rule="nonzero"/></svg>',
|
||||
'user': '<svg width="24" height="24"><path d="M12 24a12 12 0 1 1 0-24 12 12 0 0 1 0 24Zm-8.7-5.3a11 11 0 0 0 17.4 0C19.4 16.3 14.6 15 12 15c-2.6 0-7.4 1.3-8.7 3.7ZM12 13c2.2 0 4-2 4-4.5S14.2 4 12 4 8 6 8 8.5 9.8 13 12 13Z" fill-rule="nonzero"/></svg>',
|
||||
'vertical-align': '<svg width="24" height="24"><g fill-rule="nonzero"><rect width="18" height="2" x="3" y="11" rx="1"/><path d="M12 2c.6 0 1 .4 1 1v4l2-1.3a1 1 0 0 1 1.2 1.5l-.1.1-4.1 3-4-3a1 1 0 0 1 1-1.7l2 1.5V3c0-.6.4-1 1-1zm0 11.8 4 2.9a1 1 0 0 1-1 1.7l-2-1.5V21c0 .5-.4 1-.9 1H12a1 1 0 0 1-1-1v-4l-2 1.3a1 1 0 0 1-1.2-.1l-.1-.1a1 1 0 0 1 .1-1.3l.1-.1 4.1-3z"/></g></svg>',
|
||||
'vibrance': '<svg height="24" width="24"><path d="M12 20 22 4H2zm2.6-8 1.3-2H8l1.3 2h5.2zm-1.2 2h-2.8l1.4 2.2zm5-8L17 8H7L5.6 6h12.8z" fill-rule="evenodd"/></svg>',
|
||||
'visualblocks': '<svg width="24" height="24"><path d="M9 19v2H7v-2h2Zm-4 0v2a2 2 0 0 1-2-2h2Zm8 0v2h-2v-2h2Zm8 0a2 2 0 0 1-2 2v-2h2Zm-4 0v2h-2v-2h2ZM15 7a1 1 0 0 1 0 2v7a1 1 0 0 1-2 0V9h-1v7a1 1 0 0 1-2 0v-4a2.5 2.5 0 0 1-.2-5H15ZM5 15v2H3v-2h2Zm16 0v2h-2v-2h2ZM5 11v2H3v-2h2Zm16 0v2h-2v-2h2ZM5 7v2H3V7h2Zm16 0v2h-2V7h2ZM5 3v2H3c0-1.1.9-2 2-2Zm8 0v2h-2V3h2Zm6 0a2 2 0 0 1 2 2h-2V3ZM9 3v2H7V3h2Zm8 0v2h-2V3h2Z" fill-rule="evenodd"/></svg>',
|
||||
'visualchars': '<svg width="24" height="24"><path d="M10 5h7a1 1 0 0 1 0 2h-1v11a1 1 0 0 1-2 0V7h-2v11a1 1 0 0 1-2 0v-6c-.5 0-1 0-1.4-.3A3.4 3.4 0 0 1 6.8 10a3.3 3.3 0 0 1 0-2.8 3.4 3.4 0 0 1 1.8-1.8L10 5Z" fill-rule="evenodd"/></svg>',
|
||||
'vk': '<svg width="24" height="24"><path fill-rule="evenodd" d="M14.8 17.4a2 2 0 0 1-1.3.7 8 8 0 0 1-7.7-3.6c-1.4-2-2.2-4.4-2.6-5.7L3 8.6A2 2 0 0 1 5 6h2.5c.4 0 .8.1 1.2.4A2 2 0 0 1 9.8 6h3.5a2 2 0 0 1 1.4.6 2 2 0 0 1 1.5-.6h2.4a2 2 0 0 1 2 2.5c-.5 1.5-1.2 2.6-1.8 3.6a12.5 12.5 0 0 1 2 3.2A2 2 0 0 1 19 18h-2.6a2 2 0 0 1-1.3-.5 6.9 6.9 0 0 1-.3-.2Zm.5-2.5-1.2-1a3 3 0 0 0-.8-.2v2.4h-1.7a5.6 5.6 0 0 1-.3 0c-3-.7-4.7-3.7-5.6-6.1A38.1 38.1 0 0 1 5 8.1V8h2.5a80.5 80.5 0 0 1 1.2 2.5c.6 1.2 1.2 2 2 2.1V9l-.9-1h3.5v4.1a5 5 0 0 0 .4-.3c.7-.7 1.2-1.4 1.6-2.2l.2-.3.7-1.3h2.4a8.5 8.5 0 0 1-.8 2l-1.3 2-.3.3a10.5 10.5 0 0 1 2.8 3.8h-2.6l-.8-.9a20.6 20.6 0 0 0-.3-.3Z" clip-rule="evenodd"/></svg>',
|
||||
'warmth': '<svg height="24" width="24"><path d="M12 21.1c-1.4 0-2.6-.5-3.6-1.5s-1.5-2.1-1.5-3.5c0-.8.2-1.6.6-2.2A5 5 0 0 1 8.9 12v-6c0-.8.3-1.5 1-2.1a2.9 2.9 0 0 1 2-.9c.9 0 1.6.3 2.2.9.5.6.8 1.3.8 2.1v6a5 5 0 0 1 1.5 1.8A4.9 4.9 0 0 1 17 16c0 1.4-.5 2.6-1.4 3.5s-2.2 1.5-3.6 1.5zm-3-5h6c0-.5-.2-1-.4-1.4a3 3 0 0 0-.9-1l-.8-.6v-7a1 1 0 0 0-.3-.7A1 1 0 0 0 12 5a1 1 0 0 0-.7.3 1 1 0 0 0-.3.7v7l-.8.6a2.9 2.9 0 0 0-.9 1 3 3 0 0 0-.3 1.4z"/></svg>',
|
||||
'warning': '<svg width="24" height="24"><path d="M19.8 18.3c.2.5.3.9 0 1.2-.1.3-.5.5-1 .5H5.2c-.5 0-.9-.2-1-.5-.3-.3-.2-.7 0-1.2L11 4.7l.5-.5.5-.2c.2 0 .3 0 .5.2.2 0 .3.3.5.5l6.8 13.6ZM12 18c.3 0 .5-.1.7-.3.2-.2.3-.4.3-.7a1 1 0 0 0-.3-.7 1 1 0 0 0-.7-.3 1 1 0 0 0-.7.3 1 1 0 0 0-.3.7c0 .3.1.5.3.7.2.2.4.3.7.3Zm.7-3 .3-4a1 1 0 0 0-.3-.7 1 1 0 0 0-.7-.3 1 1 0 0 0-.7.3 1 1 0 0 0-.3.7l.3 4h1.4Z" fill-rule="evenodd"/></svg>',
|
||||
'web-search': '<svg width="24" height="24"><path fill-rule="evenodd" d="M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18ZM5 13a7 7 0 0 0 4.8 5.6l-.2-.2A13.3 13.3 0 0 1 8 13H5Zm11 0a13.3 13.3 0 0 1-1.6 5.3c0 .2-.1.2-.2.3A7 7 0 0 0 19 13h-3Zm-6 0a11.3 11.3 0 0 0 1.4 4.4l.6 1 .6-1A11.3 11.3 0 0 0 14 13h-4Zm-.2-7.6A7 7 0 0 0 5 11h3a13.3 13.3 0 0 1 1.5-5.3l.2-.3Zm2.2.2a11.3 11.3 0 0 0-2 5.4h4a11.3 11.3 0 0 0-2-5.4Zm2.4 0A13.3 13.3 0 0 1 16 11h3a7 7 0 0 0-4.8-5.6l.2.3Z"/></svg>',
|
||||
'zoom-in': '<svg width="24" height="24"><path d="M16 17.3a8 8 0 1 1 1.4-1.4l4.3 4.4a1 1 0 0 1-1.4 1.4l-4.4-4.3Zm-5-.3a6 6 0 1 0 0-12 6 6 0 0 0 0 12Zm-1-9a1 1 0 0 1 2 0v6a1 1 0 0 1-2 0V8Zm-2 4a1 1 0 0 1 0-2h6a1 1 0 0 1 0 2H8Z" fill-rule="nonzero"/></svg>',
|
||||
'zoom-out': '<svg width="24" height="24"><path d="M16 17.3a8 8 0 1 1 1.4-1.4l4.3 4.4a1 1 0 0 1-1.4 1.4l-4.4-4.3Zm-5-.3a6 6 0 1 0 0-12 6 6 0 0 0 0 12Zm-3-5a1 1 0 0 1 0-2h6a1 1 0 0 1 0 2H8Z" fill-rule="nonzero"/></svg>',
|
||||
}
|
||||
});
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
// Exports the "default" icons for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/icons/default')
|
||||
// ES2015:
|
||||
// import 'tinymce/icons/default'
|
||||
require('./icons.js');
|
||||
@@ -1,3 +0,0 @@
|
||||
This is where language files should be placed.
|
||||
|
||||
Please DO NOT translate these directly, use this service instead: https://crowdin.com/project/tinymce
|
||||
@@ -1,6 +1,9 @@
|
||||
# Software License Agreement
|
||||
Copyright (c) 2025 Ephox Corporation DBA Tiny Technologies, Inc.
|
||||
|
||||
**TinyMCE** – [<https://github.com/tinymce/tinymce>](https://github.com/tinymce/tinymce)
|
||||
Copyright (c) 2024, Ephox Corporation DBA Tiny Technologies, Inc.
|
||||
SOFTWARE TERMS & CONDITIONS OF USE
|
||||
|
||||
Licensed under the terms of [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html).
|
||||
Licensed under, and subject to the restrictions of:
|
||||
1. The terms of [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html); or
|
||||
2. The Tiny Technologies, Inc Software Terms & Conditions of Use presented at <https://about.tiny.cloud/legal/> and specifically the Tiny Self-Hosted License Agreement, unless you have signed a negotiated commercial license agreement with us in which case your use is governed by those terms. By use of this Software you have agreed to these Tiny Technologies, Inc Software Terms & Conditions of Use.
|
||||
|
||||
If you are uncertain about your licensing requirements, please contact <sales@tiny.cloud> for further clarification.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Exports the "dom" model for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/models/dom')
|
||||
// ES2015:
|
||||
// import 'tinymce/models/dom'
|
||||
require('./model.js');
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+1
-4
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
|
||||
Below is a list of third party libraries that this software uses:
|
||||
----------------------------------------------------------------
|
||||
|
||||
dompurify
|
||||
owner: Mario Heiderich
|
||||
repo: https://github.com/cure53/DOMPurify
|
||||
version: 3.3.2
|
||||
license: MPL-2.0 OR Apache-2.0
|
||||
|
||||
prismjs
|
||||
owner: Lea Verou
|
||||
repo: https://github.com/PrismJS/prism
|
||||
version: 1.25.0
|
||||
license: MIT
|
||||
|
||||
|
||||
prism-themes
|
||||
owner: Lea Verou
|
||||
repo: https://github.com/PrismJS/prism-themes
|
||||
version: 1.9.0
|
||||
license: MIT
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "tinymce",
|
||||
"version": "8.5.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tinymce/tinymce.git",
|
||||
"directory": "modules/tinymce"
|
||||
},
|
||||
"description": "Web based JavaScript HTML WYSIWYG editor control.",
|
||||
"author": "Ephox Corporation DBA Tiny Technologies, Inc",
|
||||
"main": "tinymce.js",
|
||||
"types": "tinymce.d.ts",
|
||||
"license": "SEE LICENSE IN license.md",
|
||||
"keywords": [
|
||||
"wysiwyg",
|
||||
"tinymce",
|
||||
"richtext",
|
||||
"javascript",
|
||||
"html",
|
||||
"text",
|
||||
"rich editor",
|
||||
"rich text editor",
|
||||
"rte",
|
||||
"rich text",
|
||||
"contenteditable",
|
||||
"editing"
|
||||
],
|
||||
"homepage": "https://www.tiny.cloud/",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tinymce/tinymce/issues"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Exports the "accordion" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/accordion')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/accordion'
|
||||
require('./plugin.js');
|
||||
File diff suppressed because it is too large
Load Diff
+1
-4
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
// Exports the "advlist" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/advlist')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/advlist'
|
||||
require('./plugin.js');
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
const applyListFormat = (editor, listName, styleValue) => {
|
||||
const cmd = listName === 'UL' ? 'InsertUnorderedList' : 'InsertOrderedList';
|
||||
editor.execCommand(cmd, false, styleValue === false ? null : { 'list-style-type': styleValue });
|
||||
};
|
||||
|
||||
const register$2 = (editor) => {
|
||||
editor.addCommand('ApplyUnorderedListStyle', (ui, value) => {
|
||||
applyListFormat(editor, 'UL', value['list-style-type']);
|
||||
});
|
||||
editor.addCommand('ApplyOrderedListStyle', (ui, value) => {
|
||||
applyListFormat(editor, 'OL', value['list-style-type']);
|
||||
});
|
||||
};
|
||||
|
||||
const option = (name) => (editor) => editor.options.get(name);
|
||||
const register$1 = (editor) => {
|
||||
const registerOption = editor.options.register;
|
||||
registerOption('advlist_number_styles', {
|
||||
processor: 'string[]',
|
||||
default: 'default,lower-alpha,lower-greek,lower-roman,upper-alpha,upper-roman'.split(',')
|
||||
});
|
||||
registerOption('advlist_bullet_styles', {
|
||||
processor: 'string[]',
|
||||
default: 'default,disc,circle,square'.split(',')
|
||||
});
|
||||
};
|
||||
const getNumberStyles = option('advlist_number_styles');
|
||||
const getBulletStyles = option('advlist_bullet_styles');
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-wrapper-object-types */
|
||||
const isNullable = (a) => a === null || a === undefined;
|
||||
const isNonNullable = (a) => !isNullable(a);
|
||||
|
||||
/**
|
||||
* The `Optional` type represents a value (of any type) that potentially does
|
||||
* not exist. Any `Optional<T>` can either be a `Some<T>` (in which case the
|
||||
* value does exist) or a `None` (in which case the value does not exist). This
|
||||
* module defines a whole lot of FP-inspired utility functions for dealing with
|
||||
* `Optional` objects.
|
||||
*
|
||||
* Comparison with null or undefined:
|
||||
* - We don't get fancy null coalescing operators with `Optional`
|
||||
* - We do get fancy helper functions with `Optional`
|
||||
* - `Optional` support nesting, and allow for the type to still be nullable (or
|
||||
* another `Optional`)
|
||||
* - There is no option to turn off strict-optional-checks like there is for
|
||||
* strict-null-checks
|
||||
*/
|
||||
class Optional {
|
||||
tag;
|
||||
value;
|
||||
// Sneaky optimisation: every instance of Optional.none is identical, so just
|
||||
// reuse the same object
|
||||
static singletonNone = new Optional(false);
|
||||
// The internal representation has a `tag` and a `value`, but both are
|
||||
// private: able to be console.logged, but not able to be accessed by code
|
||||
constructor(tag, value) {
|
||||
this.tag = tag;
|
||||
this.value = value;
|
||||
}
|
||||
// --- Identities ---
|
||||
/**
|
||||
* Creates a new `Optional<T>` that **does** contain a value.
|
||||
*/
|
||||
static some(value) {
|
||||
return new Optional(true, value);
|
||||
}
|
||||
/**
|
||||
* Create a new `Optional<T>` that **does not** contain a value. `T` can be
|
||||
* any type because we don't actually have a `T`.
|
||||
*/
|
||||
static none() {
|
||||
return Optional.singletonNone;
|
||||
}
|
||||
/**
|
||||
* Perform a transform on an `Optional` type. Regardless of whether this
|
||||
* `Optional` contains a value or not, `fold` will return a value of type `U`.
|
||||
* If this `Optional` does not contain a value, the `U` will be created by
|
||||
* calling `onNone`. If this `Optional` does contain a value, the `U` will be
|
||||
* created by calling `onSome`.
|
||||
*
|
||||
* For the FP enthusiasts in the room, this function:
|
||||
* 1. Could be used to implement all of the functions below
|
||||
* 2. Forms a catamorphism
|
||||
*/
|
||||
fold(onNone, onSome) {
|
||||
if (this.tag) {
|
||||
return onSome(this.value);
|
||||
}
|
||||
else {
|
||||
return onNone();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Determine if this `Optional` object contains a value.
|
||||
*/
|
||||
isSome() {
|
||||
return this.tag;
|
||||
}
|
||||
/**
|
||||
* Determine if this `Optional` object **does not** contain a value.
|
||||
*/
|
||||
isNone() {
|
||||
return !this.tag;
|
||||
}
|
||||
// --- Functor (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* Perform a transform on an `Optional` object, **if** there is a value. If
|
||||
* you provide a function to turn a T into a U, this is the function you use
|
||||
* to turn an `Optional<T>` into an `Optional<U>`. If this **does** contain
|
||||
* a value then the output will also contain a value (that value being the
|
||||
* output of `mapper(this.value)`), and if this **does not** contain a value
|
||||
* then neither will the output.
|
||||
*/
|
||||
map(mapper) {
|
||||
if (this.tag) {
|
||||
return Optional.some(mapper(this.value));
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Monad (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* Perform a transform on an `Optional` object, **if** there is a value.
|
||||
* Unlike `map`, here the transform itself also returns an `Optional`.
|
||||
*/
|
||||
bind(binder) {
|
||||
if (this.tag) {
|
||||
return binder(this.value);
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Traversable (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* For a given predicate, this function finds out if there **exists** a value
|
||||
* inside this `Optional` object that meets the predicate. In practice, this
|
||||
* means that for `Optional`s that do not contain a value it returns false (as
|
||||
* no predicate-meeting value exists).
|
||||
*/
|
||||
exists(predicate) {
|
||||
return this.tag && predicate(this.value);
|
||||
}
|
||||
/**
|
||||
* For a given predicate, this function finds out if **all** the values inside
|
||||
* this `Optional` object meet the predicate. In practice, this means that
|
||||
* for `Optional`s that do not contain a value it returns true (as all 0
|
||||
* objects do meet the predicate).
|
||||
*/
|
||||
forall(predicate) {
|
||||
return !this.tag || predicate(this.value);
|
||||
}
|
||||
filter(predicate) {
|
||||
if (!this.tag || predicate(this.value)) {
|
||||
return this;
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Getters ---
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value.
|
||||
*/
|
||||
getOr(replacement) {
|
||||
return this.tag ? this.value : replacement;
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value. Unlike `getOr`, in this method the `replacement` object is also
|
||||
* `Optional` - meaning that this method will always return an `Optional`.
|
||||
*/
|
||||
or(replacement) {
|
||||
return this.tag ? this : replacement;
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value. Unlike `getOr`, in this method the `replacement` value is
|
||||
* "thunked" - that is to say that you don't pass a value to `getOrThunk`, you
|
||||
* pass a function which (if called) will **return** the `value` you want to
|
||||
* use.
|
||||
*/
|
||||
getOrThunk(thunk) {
|
||||
return this.tag ? this.value : thunk();
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided Optional object does not contain a
|
||||
* value.
|
||||
*
|
||||
* Unlike `or`, in this method the `replacement` value is "thunked" - that is
|
||||
* to say that you don't pass a value to `orThunk`, you pass a function which
|
||||
* (if called) will **return** the `value` you want to use.
|
||||
*
|
||||
* Unlike `getOrThunk`, in this method the `replacement` value is also
|
||||
* `Optional`, meaning that this method will always return an `Optional`.
|
||||
*/
|
||||
orThunk(thunk) {
|
||||
return this.tag ? this : thunk();
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, throwing an
|
||||
* exception if the provided `Optional` object does not contain a value.
|
||||
*
|
||||
* WARNING:
|
||||
* You should only be using this function if you know that the `Optional`
|
||||
* object **is not** empty (otherwise you're throwing exceptions in production
|
||||
* code, which is bad).
|
||||
*
|
||||
* In tests this is more acceptable.
|
||||
*
|
||||
* Prefer other methods to this, such as `.each`.
|
||||
*/
|
||||
getOrDie(message) {
|
||||
if (!this.tag) {
|
||||
throw new Error(message ?? 'Called getOrDie on None');
|
||||
}
|
||||
else {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
// --- Interop with null and undefined ---
|
||||
/**
|
||||
* Creates an `Optional` value from a nullable (or undefined-able) input.
|
||||
* Null, or undefined, is converted to `None`, and anything else is converted
|
||||
* to `Some`.
|
||||
*/
|
||||
static from(value) {
|
||||
return isNonNullable(value) ? Optional.some(value) : Optional.none();
|
||||
}
|
||||
/**
|
||||
* Converts an `Optional` to a nullable type, by getting the value if it
|
||||
* exists, or returning `null` if it does not.
|
||||
*/
|
||||
getOrNull() {
|
||||
return this.tag ? this.value : null;
|
||||
}
|
||||
/**
|
||||
* Converts an `Optional` to an undefined-able type, by getting the value if
|
||||
* it exists, or returning `undefined` if it does not.
|
||||
*/
|
||||
getOrUndefined() {
|
||||
return this.value;
|
||||
}
|
||||
// --- Utilities ---
|
||||
/**
|
||||
* If the `Optional` contains a value, perform an action on that value.
|
||||
* Unlike the rest of the methods on this type, `.each` has side-effects. If
|
||||
* you want to transform an `Optional<T>` **into** something, then this is not
|
||||
* the method for you. If you want to use an `Optional<T>` to **do**
|
||||
* something, then this is the method for you - provided you're okay with not
|
||||
* doing anything in the case where the `Optional` doesn't have a value inside
|
||||
* it. If you're not sure whether your use-case fits into transforming
|
||||
* **into** something or **doing** something, check whether it has a return
|
||||
* value. If it does, you should be performing a transform.
|
||||
*/
|
||||
each(worker) {
|
||||
if (this.tag) {
|
||||
worker(this.value);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Turn the `Optional` object into an array that contains all of the values
|
||||
* stored inside the `Optional`. In practice, this means the output will have
|
||||
* either 0 or 1 elements.
|
||||
*/
|
||||
toArray() {
|
||||
return this.tag ? [this.value] : [];
|
||||
}
|
||||
/**
|
||||
* Turn the `Optional` object into a string for debugging or printing. Not
|
||||
* recommended for production code, but good for debugging. Also note that
|
||||
* these days an `Optional` object can be logged to the console directly, and
|
||||
* its inner value (if it exists) will be visible.
|
||||
*/
|
||||
toString() {
|
||||
return this.tag ? `some(${this.value})` : 'none()';
|
||||
}
|
||||
}
|
||||
|
||||
const nativeIndexOf = Array.prototype.indexOf;
|
||||
const rawIndexOf = (ts, t) => nativeIndexOf.call(ts, t);
|
||||
const contains = (xs, x) => rawIndexOf(xs, x) > -1;
|
||||
const findUntil = (xs, pred, until) => {
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
if (pred(x, i)) {
|
||||
return Optional.some(x);
|
||||
}
|
||||
else if (until(x, i)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Optional.none();
|
||||
};
|
||||
|
||||
// There are many variations of Object iteration that are faster than the 'for-in' style:
|
||||
// http://jsperf.com/object-keys-iteration/107
|
||||
//
|
||||
// Use the native keys if it is available (IE9+), otherwise fall back to manually filtering
|
||||
const keys = Object.keys;
|
||||
const each = (obj, f) => {
|
||||
const props = keys(obj);
|
||||
for (let k = 0, len = props.length; k < len; k++) {
|
||||
const i = props[k];
|
||||
const x = obj[i];
|
||||
f(x, i);
|
||||
}
|
||||
};
|
||||
const map = (obj, f) => {
|
||||
return tupleMap(obj, (x, i) => ({
|
||||
k: i,
|
||||
v: f(x, i)
|
||||
}));
|
||||
};
|
||||
const tupleMap = (obj, f) => {
|
||||
const r = {};
|
||||
each(obj, (x, i) => {
|
||||
const tuple = f(x, i);
|
||||
r[tuple.k] = tuple.v;
|
||||
});
|
||||
return r;
|
||||
};
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.util.Tools');
|
||||
|
||||
const isCustomList = (list) => /\btox\-/.test(list.className);
|
||||
const isChildOfBody = (editor, elm) => {
|
||||
return editor.dom.isChildOf(elm, editor.getBody());
|
||||
};
|
||||
const matchNodeNames = (regex) => (node) => isNonNullable(node) && regex.test(node.nodeName);
|
||||
const isListNode = matchNodeNames(/^(OL|UL|DL)$/);
|
||||
const isTableCellNode = matchNodeNames(/^(TH|TD)$/);
|
||||
const inList = (editor, parents, nodeName) => findUntil(parents, (parent) => isListNode(parent) && !isCustomList(parent), isTableCellNode)
|
||||
.exists((list) => list.nodeName === nodeName && isChildOfBody(editor, list));
|
||||
const getSelectedStyleType = (editor) => {
|
||||
const listElm = editor.dom.getParent(editor.selection.getNode(), 'ol,ul');
|
||||
const style = editor.dom.getStyle(listElm, 'listStyleType');
|
||||
return Optional.from(style);
|
||||
};
|
||||
// Lists/core/Util.ts - Duplicated in Lists plugin
|
||||
const isWithinNonEditable = (editor, element) => element !== null && !editor.dom.isEditable(element);
|
||||
const isWithinNonEditableList = (editor, element) => {
|
||||
const parentList = editor.dom.getParent(element, 'ol,ul,dl');
|
||||
return isWithinNonEditable(editor, parentList) || !editor.selection.isEditable();
|
||||
};
|
||||
const setNodeChangeHandler = (editor, nodeChangeHandler) => {
|
||||
const initialNode = editor.selection.getNode();
|
||||
// Set the initial state
|
||||
nodeChangeHandler({
|
||||
parents: editor.dom.getParents(initialNode),
|
||||
element: initialNode
|
||||
});
|
||||
editor.on('NodeChange', nodeChangeHandler);
|
||||
return () => editor.off('NodeChange', nodeChangeHandler);
|
||||
};
|
||||
|
||||
// <ListStyles>
|
||||
const styleValueToText = (styleValue) => {
|
||||
return styleValue.replace(/\-/g, ' ').replace(/\b\w/g, (chr) => {
|
||||
return chr.toUpperCase();
|
||||
});
|
||||
};
|
||||
const normalizeStyleValue = (styleValue) => isNullable(styleValue) || styleValue === 'default' ? '' : styleValue;
|
||||
const makeSetupHandler = (editor, nodeName) => (api) => {
|
||||
const updateButtonState = (editor, parents) => {
|
||||
const element = editor.selection.getStart(true);
|
||||
api.setActive(inList(editor, parents, nodeName));
|
||||
api.setEnabled(!isWithinNonEditableList(editor, element));
|
||||
};
|
||||
const nodeChangeHandler = (e) => updateButtonState(editor, e.parents);
|
||||
return setNodeChangeHandler(editor, nodeChangeHandler);
|
||||
};
|
||||
const addSplitButton = (editor, id, tooltip, cmd, nodeName, styles) => {
|
||||
const listStyleTypeAliases = {
|
||||
'lower-latin': 'lower-alpha',
|
||||
'upper-latin': 'upper-alpha',
|
||||
'lower-alpha': 'lower-latin',
|
||||
'upper-alpha': 'upper-latin'
|
||||
};
|
||||
const stylesContainsAliasMap = map(listStyleTypeAliases, (alias) => contains(styles, alias));
|
||||
editor.ui.registry.addSplitButton(id, {
|
||||
tooltip,
|
||||
chevronTooltip: tooltip,
|
||||
icon: nodeName === "OL" /* ListType.OrderedList */ ? 'ordered-list' : 'unordered-list',
|
||||
presets: 'listpreview',
|
||||
columns: nodeName === "OL" /* ListType.OrderedList */ ? 3 : 4,
|
||||
fetch: (callback) => {
|
||||
const items = global.map(styles, (styleValue) => {
|
||||
const iconStyle = nodeName === "OL" /* ListType.OrderedList */ ? 'num' : 'bull';
|
||||
const iconName = styleValue === 'decimal' ? 'default' : styleValue;
|
||||
const itemValue = normalizeStyleValue(styleValue);
|
||||
const displayText = styleValueToText(styleValue);
|
||||
return {
|
||||
type: 'choiceitem',
|
||||
value: itemValue,
|
||||
icon: 'list-' + iconStyle + '-' + iconName,
|
||||
text: displayText
|
||||
};
|
||||
});
|
||||
callback(items);
|
||||
},
|
||||
onAction: () => editor.execCommand(cmd),
|
||||
onItemAction: (_splitButtonApi, value) => {
|
||||
applyListFormat(editor, nodeName, value);
|
||||
},
|
||||
select: (value) => {
|
||||
const listStyleType = getSelectedStyleType(editor);
|
||||
return listStyleType.exists((listStyle) => value === listStyle || (listStyleTypeAliases[listStyle] === value && !stylesContainsAliasMap[value]));
|
||||
},
|
||||
onSetup: makeSetupHandler(editor, nodeName)
|
||||
});
|
||||
};
|
||||
const addButton = (editor, id, tooltip, cmd, nodeName, styleValue) => {
|
||||
editor.ui.registry.addToggleButton(id, {
|
||||
active: false,
|
||||
tooltip,
|
||||
icon: nodeName === "OL" /* ListType.OrderedList */ ? 'ordered-list' : 'unordered-list',
|
||||
onSetup: makeSetupHandler(editor, nodeName),
|
||||
// Need to make sure the button removes rather than applies if a list of the same type is selected
|
||||
onAction: () => editor.queryCommandState(cmd) || styleValue === '' ? editor.execCommand(cmd) : applyListFormat(editor, nodeName, styleValue)
|
||||
});
|
||||
};
|
||||
const addControl = (editor, id, tooltip, cmd, nodeName, styles) => {
|
||||
if (styles.length > 1) {
|
||||
addSplitButton(editor, id, tooltip, cmd, nodeName, styles);
|
||||
}
|
||||
else {
|
||||
addButton(editor, id, tooltip, cmd, nodeName, normalizeStyleValue(styles[0]));
|
||||
}
|
||||
};
|
||||
const register = (editor) => {
|
||||
addControl(editor, 'numlist', 'Numbered list', 'InsertOrderedList', "OL" /* ListType.OrderedList */, getNumberStyles(editor));
|
||||
addControl(editor, 'bullist', 'Bullet list', 'InsertUnorderedList', "UL" /* ListType.UnorderedList */, getBulletStyles(editor));
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global$1.add('advlist', (editor) => {
|
||||
if (editor.hasPlugin('lists')) {
|
||||
register$1(editor);
|
||||
register(editor);
|
||||
register$2(editor);
|
||||
}
|
||||
else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Please use the Lists plugin together with the List Styles plugin.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
+1
-4
@@ -1,4 +1 @@
|
||||
/**
|
||||
* TinyMCE version 7.3.0 (2024-08-07)
|
||||
*/
|
||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=(t,e,s)=>{const r="UL"===e?"InsertUnorderedList":"InsertOrderedList";t.execCommand(r,!1,!1===s?null:{"list-style-type":s})},s=t=>e=>e.options.get(t),r=s("advlist_number_styles"),n=s("advlist_bullet_styles"),i=t=>null==t,l=t=>!i(t);var o=tinymce.util.Tools.resolve("tinymce.util.Tools");class a{constructor(t,e){this.tag=t,this.value=e}static some(t){return new a(!0,t)}static none(){return a.singletonNone}fold(t,e){return this.tag?e(this.value):t()}isSome(){return this.tag}isNone(){return!this.tag}map(t){return this.tag?a.some(t(this.value)):a.none()}bind(t){return this.tag?t(this.value):a.none()}exists(t){return this.tag&&t(this.value)}forall(t){return!this.tag||t(this.value)}filter(t){return!this.tag||t(this.value)?this:a.none()}getOr(t){return this.tag?this.value:t}or(t){return this.tag?this:t}getOrThunk(t){return this.tag?this.value:t()}orThunk(t){return this.tag?this:t()}getOrDie(t){if(this.tag)return this.value;throw new Error(null!=t?t:"Called getOrDie on None")}static from(t){return l(t)?a.some(t):a.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(t){this.tag&&t(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}a.singletonNone=new a(!1);const u=t=>e=>l(e)&&t.test(e.nodeName),d=u(/^(OL|UL|DL)$/),g=u(/^(TH|TD)$/),c=t=>i(t)||"default"===t?"":t,h=(t,e)=>s=>((t,e)=>{const s=t.selection.getNode();return e({parents:t.dom.getParents(s),element:s}),t.on("NodeChange",e),()=>t.off("NodeChange",e)})(t,(r=>((t,r)=>{const n=t.selection.getStart(!0);s.setActive(((t,e,s)=>((t,e,s)=>{for(let e=0,n=t.length;e<n;e++){const n=t[e];if(d(r=n)&&!/\btox\-/.test(r.className))return a.some(n);if(s(n,e))break}var r;return a.none()})(e,0,g).exists((e=>e.nodeName===s&&((t,e)=>t.dom.isChildOf(e,t.getBody()))(t,e))))(t,r,e)),s.setEnabled(!((t,e)=>{const s=t.dom.getParent(e,"ol,ul,dl");return((t,e)=>null!==e&&!t.dom.isEditable(e))(t,s)&&t.selection.isEditable()})(t,n)&&t.selection.isEditable())})(t,r.parents))),m=(t,s,r,n,i,l)=>{l.length>1?((t,s,r,n,i,l)=>{t.ui.registry.addSplitButton(s,{tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",presets:"listpreview",columns:3,fetch:t=>{t(o.map(l,(t=>{const e="OL"===i?"num":"bull",s="disc"===t||"decimal"===t?"default":t,r=c(t),n=(t=>t.replace(/\-/g," ").replace(/\b\w/g,(t=>t.toUpperCase())))(t);return{type:"choiceitem",value:r,icon:"list-"+e+"-"+s,text:n}})))},onAction:()=>t.execCommand(n),onItemAction:(s,r)=>{e(t,i,r)},select:e=>{const s=(t=>{const e=t.dom.getParent(t.selection.getNode(),"ol,ul"),s=t.dom.getStyle(e,"listStyleType");return a.from(s)})(t);return s.map((t=>e===t)).getOr(!1)},onSetup:h(t,i)})})(t,s,r,n,i,l):((t,s,r,n,i,l)=>{t.ui.registry.addToggleButton(s,{active:!1,tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",onSetup:h(t,i),onAction:()=>t.queryCommandState(n)||""===l?t.execCommand(n):e(t,i,l)})})(t,s,r,n,i,c(l[0]))};t.add("advlist",(t=>{t.hasPlugin("lists")?((t=>{const e=t.options.register;e("advlist_number_styles",{processor:"string[]",default:"default,lower-alpha,lower-greek,lower-roman,upper-alpha,upper-roman".split(",")}),e("advlist_bullet_styles",{processor:"string[]",default:"default,circle,square".split(",")})})(t),(t=>{m(t,"numlist","Numbered list","InsertOrderedList","OL",r(t)),m(t,"bullist","Bullet list","InsertUnorderedList","UL",n(t))})(t),(t=>{t.addCommand("ApplyUnorderedListStyle",((s,r)=>{e(t,"UL",r["list-style-type"])})),t.addCommand("ApplyOrderedListStyle",((s,r)=>{e(t,"OL",r["list-style-type"])}))})(t)):console.error("Please use the Lists plugin together with the List Styles plugin.")}))}();
|
||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=(t,e,s)=>{const r="UL"===e?"InsertUnorderedList":"InsertOrderedList";t.execCommand(r,!1,!1===s?null:{"list-style-type":s})},s=t=>e=>e.options.get(t),r=s("advlist_number_styles"),n=s("advlist_bullet_styles"),l=t=>null==t,i=t=>!l(t);class o{tag;value;static singletonNone=new o(!1);constructor(t,e){this.tag=t,this.value=e}static some(t){return new o(!0,t)}static none(){return o.singletonNone}fold(t,e){return this.tag?e(this.value):t()}isSome(){return this.tag}isNone(){return!this.tag}map(t){return this.tag?o.some(t(this.value)):o.none()}bind(t){return this.tag?t(this.value):o.none()}exists(t){return this.tag&&t(this.value)}forall(t){return!this.tag||t(this.value)}filter(t){return!this.tag||t(this.value)?this:o.none()}getOr(t){return this.tag?this.value:t}or(t){return this.tag?this:t}getOrThunk(t){return this.tag?this.value:t()}orThunk(t){return this.tag?this:t()}getOrDie(t){if(this.tag)return this.value;throw new Error(t??"Called getOrDie on None")}static from(t){return i(t)?o.some(t):o.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(t){this.tag&&t(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}const a=Array.prototype.indexOf,u=Object.keys;var d=tinymce.util.Tools.resolve("tinymce.util.Tools");const c=t=>e=>i(e)&&t.test(e.nodeName),h=c(/^(OL|UL|DL)$/),g=c(/^(TH|TD)$/),p=t=>l(t)||"default"===t?"":t,m=(t,e)=>s=>((t,e)=>{const s=t.selection.getNode();return e({parents:t.dom.getParents(s),element:s}),t.on("NodeChange",e),()=>t.off("NodeChange",e)})(t,r=>((t,r)=>{const n=t.selection.getStart(!0);s.setActive(((t,e,s)=>((t,e,s)=>{for(let r=0,n=t.length;r<n;r++){const n=t[r];if(e(n))return o.some(n);if(s(n,r))break}return o.none()})(e,t=>h(t)&&!/\btox\-/.test(t.className),g).exists(e=>e.nodeName===s&&((t,e)=>t.dom.isChildOf(e,t.getBody()))(t,e)))(t,r,e)),s.setEnabled(!((t,e)=>{const s=t.dom.getParent(e,"ol,ul,dl");return((t,e)=>null!==e&&!t.dom.isEditable(e))(t,s)||!t.selection.isEditable()})(t,n))})(t,r.parents)),v=(t,s,r,n,l,i)=>{const c={"lower-latin":"lower-alpha","upper-latin":"upper-alpha","lower-alpha":"lower-latin","upper-alpha":"upper-latin"},h=(g=t=>{return e=i,s=t,a.call(e,s)>-1;var e,s},((t,e)=>{const s={};return((t,e)=>{const s=u(t);for(let r=0,n=s.length;r<n;r++){const n=s[r];e(t[n],n)}})(t,(t,r)=>{const n=e(t,r);s[n.k]=n.v}),s})(c,(t,e)=>({k:e,v:g(t)})));var g;t.ui.registry.addSplitButton(s,{tooltip:r,chevronTooltip:r,icon:"OL"===l?"ordered-list":"unordered-list",presets:"listpreview",columns:"OL"===l?3:4,fetch:t=>{t(d.map(i,t=>{const e="OL"===l?"num":"bull",s="decimal"===t?"default":t,r=p(t),n=(t=>t.replace(/\-/g," ").replace(/\b\w/g,t=>t.toUpperCase()))(t);return{type:"choiceitem",value:r,icon:"list-"+e+"-"+s,text:n}}))},onAction:()=>t.execCommand(n),onItemAction:(s,r)=>{e(t,l,r)},select:e=>{const s=(t=>{const e=t.dom.getParent(t.selection.getNode(),"ol,ul"),s=t.dom.getStyle(e,"listStyleType");return o.from(s)})(t);return s.exists(t=>e===t||c[t]===e&&!h[e])},onSetup:m(t,l)})},y=(t,s,r,n,l,i)=>{i.length>1?v(t,s,r,n,l,i):((t,s,r,n,l,i)=>{t.ui.registry.addToggleButton(s,{active:!1,tooltip:r,icon:"OL"===l?"ordered-list":"unordered-list",onSetup:m(t,l),onAction:()=>t.queryCommandState(n)||""===i?t.execCommand(n):e(t,l,i)})})(t,s,r,n,l,p(i[0]))};t.add("advlist",t=>{t.hasPlugin("lists")?((t=>{const e=t.options.register;e("advlist_number_styles",{processor:"string[]",default:"default,lower-alpha,lower-greek,lower-roman,upper-alpha,upper-roman".split(",")}),e("advlist_bullet_styles",{processor:"string[]",default:"default,disc,circle,square".split(",")})})(t),(t=>{y(t,"numlist","Numbered list","InsertOrderedList","OL",r(t)),y(t,"bullist","Bullet list","InsertUnorderedList","UL",n(t))})(t),(t=>{t.addCommand("ApplyUnorderedListStyle",(s,r)=>{e(t,"UL",r["list-style-type"])}),t.addCommand("ApplyOrderedListStyle",(s,r)=>{e(t,"OL",r["list-style-type"])})})(t)):console.error("Please use the Lists plugin together with the List Styles plugin.")})}();
|
||||
@@ -0,0 +1,7 @@
|
||||
// Exports the "anchor" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/anchor')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/anchor'
|
||||
require('./plugin.js');
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global$2 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.dom.RangeUtils');
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.util.Tools');
|
||||
|
||||
const option = (name) => (editor) => editor.options.get(name);
|
||||
const register$2 = (editor) => {
|
||||
const registerOption = editor.options.register;
|
||||
registerOption('allow_html_in_named_anchor', {
|
||||
processor: 'boolean',
|
||||
default: false
|
||||
});
|
||||
};
|
||||
const allowHtmlInNamedAnchor = option('allow_html_in_named_anchor');
|
||||
|
||||
const namedAnchorSelector = 'a:not([href])';
|
||||
const isEmptyString = (str) => !str;
|
||||
const getIdFromAnchor = (elm) => {
|
||||
const id = elm.getAttribute('id') || elm.getAttribute('name');
|
||||
return id || '';
|
||||
};
|
||||
const isAnchor = (elm) => elm.nodeName.toLowerCase() === 'a';
|
||||
const isNamedAnchor = (elm) => isAnchor(elm) && !elm.getAttribute('href') && getIdFromAnchor(elm) !== '';
|
||||
const isEmptyNamedAnchor = (elm) => isNamedAnchor(elm) && !elm.firstChild;
|
||||
|
||||
const removeEmptyNamedAnchorsInSelection = (editor) => {
|
||||
const dom = editor.dom;
|
||||
global$1(dom).walk(editor.selection.getRng(), (nodes) => {
|
||||
global.each(nodes, (node) => {
|
||||
if (isEmptyNamedAnchor(node)) {
|
||||
dom.remove(node, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
const isValidId = (id) =>
|
||||
// Follows HTML4 rules: https://www.w3.org/TR/html401/types.html#type-id
|
||||
/^[A-Za-z][A-Za-z0-9\-:._]*$/.test(id);
|
||||
const getNamedAnchor = (editor) => editor.dom.getParent(editor.selection.getStart(), namedAnchorSelector);
|
||||
const getId = (editor) => {
|
||||
const anchor = getNamedAnchor(editor);
|
||||
if (anchor) {
|
||||
return getIdFromAnchor(anchor);
|
||||
}
|
||||
else {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
const createAnchor = (editor, id) => {
|
||||
editor.undoManager.transact(() => {
|
||||
if (!allowHtmlInNamedAnchor(editor)) {
|
||||
editor.selection.collapse(true);
|
||||
}
|
||||
if (editor.selection.isCollapsed()) {
|
||||
editor.insertContent(editor.dom.createHTML('a', { id }));
|
||||
}
|
||||
else {
|
||||
// Remove any empty named anchors in the selection as they cannot be removed by the formatter since they are cef
|
||||
removeEmptyNamedAnchorsInSelection(editor);
|
||||
// Format is set up to truncate any partially selected named anchors so that they are not completely removed
|
||||
editor.formatter.remove('namedAnchor', undefined, undefined, true);
|
||||
// Insert new anchor using the formatter - will wrap selected content in anchor
|
||||
editor.formatter.apply('namedAnchor', { value: id });
|
||||
// Need to add visual classes to anchors if required
|
||||
editor.addVisual();
|
||||
}
|
||||
});
|
||||
};
|
||||
const updateAnchor = (editor, id, anchorElement) => {
|
||||
anchorElement.removeAttribute('name');
|
||||
anchorElement.id = id;
|
||||
editor.addVisual(); // Need to add visual classes to anchors if required
|
||||
editor.undoManager.add();
|
||||
};
|
||||
const insert = (editor, id) => {
|
||||
const anchor = getNamedAnchor(editor);
|
||||
if (anchor) {
|
||||
updateAnchor(editor, id, anchor);
|
||||
}
|
||||
else {
|
||||
createAnchor(editor, id);
|
||||
}
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
const insertAnchor = (editor, newId) => {
|
||||
if (!isValidId(newId)) {
|
||||
editor.windowManager.alert('ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.');
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
insert(editor, newId);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
const open = (editor) => {
|
||||
const currentId = getId(editor);
|
||||
editor.windowManager.open({
|
||||
title: 'Anchor',
|
||||
size: 'normal',
|
||||
body: {
|
||||
type: 'panel',
|
||||
items: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'input',
|
||||
label: 'ID',
|
||||
placeholder: 'example'
|
||||
}
|
||||
]
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
type: 'cancel',
|
||||
name: 'cancel',
|
||||
text: 'Cancel'
|
||||
},
|
||||
{
|
||||
type: 'submit',
|
||||
name: 'save',
|
||||
text: 'Save',
|
||||
primary: true
|
||||
}
|
||||
],
|
||||
initialData: {
|
||||
id: currentId
|
||||
},
|
||||
onSubmit: (api) => {
|
||||
if (insertAnchor(editor, api.getData().id)) { // TODO we need a better way to do validation
|
||||
api.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const register$1 = (editor) => {
|
||||
editor.addCommand('mceAnchor', () => {
|
||||
open(editor);
|
||||
});
|
||||
};
|
||||
|
||||
// Note: node.firstChild check is for the 'allow_html_in_named_anchor' setting
|
||||
// Only want to add contenteditable attributes if there is no text within the anchor
|
||||
const isNamedAnchorNode = (node) => isEmptyString(node.attr('href')) && !isEmptyString(node.attr('id') || node.attr('name'));
|
||||
const isEmptyNamedAnchorNode = (node) => isNamedAnchorNode(node) && !node.firstChild;
|
||||
const setContentEditable = (state) => (nodes) => {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
if (isEmptyNamedAnchorNode(node)) {
|
||||
node.attr('contenteditable', state);
|
||||
}
|
||||
}
|
||||
};
|
||||
const setup = (editor) => {
|
||||
editor.on('PreInit', () => {
|
||||
editor.parser.addNodeFilter('a', setContentEditable('false'));
|
||||
editor.serializer.addNodeFilter('a', setContentEditable(null));
|
||||
});
|
||||
};
|
||||
|
||||
const registerFormats = (editor) => {
|
||||
editor.formatter.register('namedAnchor', {
|
||||
inline: 'a',
|
||||
selector: namedAnchorSelector,
|
||||
remove: 'all',
|
||||
split: true,
|
||||
deep: true,
|
||||
attributes: {
|
||||
id: '%value'
|
||||
},
|
||||
onmatch: (node, _fmt, _itemName) => {
|
||||
return isNamedAnchor(node);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSetupEditable = (editor) => (api) => {
|
||||
const nodeChanged = () => {
|
||||
api.setEnabled(editor.selection.isEditable());
|
||||
};
|
||||
editor.on('NodeChange', nodeChanged);
|
||||
nodeChanged();
|
||||
return () => {
|
||||
editor.off('NodeChange', nodeChanged);
|
||||
};
|
||||
};
|
||||
const register = (editor) => {
|
||||
const onAction = () => editor.execCommand('mceAnchor');
|
||||
editor.ui.registry.addToggleButton('anchor', {
|
||||
icon: 'bookmark',
|
||||
tooltip: 'Anchor',
|
||||
onAction,
|
||||
onSetup: (buttonApi) => {
|
||||
const unbindSelectorChanged = editor.selection.selectorChangedWithUnbind('a:not([href])', buttonApi.setActive).unbind;
|
||||
const unbindEditableChanged = onSetupEditable(editor)(buttonApi);
|
||||
return () => {
|
||||
unbindSelectorChanged();
|
||||
unbindEditableChanged();
|
||||
};
|
||||
}
|
||||
});
|
||||
editor.ui.registry.addMenuItem('anchor', {
|
||||
icon: 'bookmark',
|
||||
text: 'Anchor...',
|
||||
onAction,
|
||||
onSetup: onSetupEditable(editor)
|
||||
});
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global$2.add('anchor', (editor) => {
|
||||
register$2(editor);
|
||||
setup(editor);
|
||||
register$1(editor);
|
||||
register(editor);
|
||||
editor.on('PreInit', () => {
|
||||
registerFormats(editor);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
+1
-4
@@ -1,4 +1 @@
|
||||
/**
|
||||
* TinyMCE version 7.3.0 (2024-08-07)
|
||||
*/
|
||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.dom.RangeUtils"),o=tinymce.util.Tools.resolve("tinymce.util.Tools");const n=("allow_html_in_named_anchor",e=>e.options.get("allow_html_in_named_anchor"));const a="a:not([href])",r=e=>!e,i=e=>e.getAttribute("id")||e.getAttribute("name")||"",l=e=>(e=>"a"===e.nodeName.toLowerCase())(e)&&!e.getAttribute("href")&&""!==i(e),s=e=>e.dom.getParent(e.selection.getStart(),a),d=(e,a)=>{const r=s(e);r?((e,t,o)=>{o.removeAttribute("name"),o.id=t,e.addVisual(),e.undoManager.add()})(e,a,r):((e,a)=>{e.undoManager.transact((()=>{n(e)||e.selection.collapse(!0),e.selection.isCollapsed()?e.insertContent(e.dom.createHTML("a",{id:a})):((e=>{const n=e.dom;t(n).walk(e.selection.getRng(),(e=>{o.each(e,(e=>{var t;l(t=e)&&!t.firstChild&&n.remove(e,!1)}))}))})(e),e.formatter.remove("namedAnchor",void 0,void 0,!0),e.formatter.apply("namedAnchor",{value:a}),e.addVisual())}))})(e,a),e.focus()},c=e=>(e=>r(e.attr("href"))&&!r(e.attr("id")||e.attr("name")))(e)&&!e.firstChild,m=e=>t=>{for(let o=0;o<t.length;o++){const n=t[o];c(n)&&n.attr("contenteditable",e)}},u=e=>t=>{const o=()=>{t.setEnabled(e.selection.isEditable())};return e.on("NodeChange",o),o(),()=>{e.off("NodeChange",o)}};e.add("anchor",(e=>{(e=>{(0,e.options.register)("allow_html_in_named_anchor",{processor:"boolean",default:!1})})(e),(e=>{e.on("PreInit",(()=>{e.parser.addNodeFilter("a",m("false")),e.serializer.addNodeFilter("a",m(null))}))})(e),(e=>{e.addCommand("mceAnchor",(()=>{(e=>{const t=(e=>{const t=s(e);return t?i(t):""})(e);e.windowManager.open({title:"Anchor",size:"normal",body:{type:"panel",items:[{name:"id",type:"input",label:"ID",placeholder:"example"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{id:t},onSubmit:t=>{((e,t)=>/^[A-Za-z][A-Za-z0-9\-:._]*$/.test(t)?(d(e,t),!0):(e.windowManager.alert("ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores."),!1))(e,t.getData().id)&&t.close()}})})(e)}))})(e),(e=>{const t=()=>e.execCommand("mceAnchor");e.ui.registry.addToggleButton("anchor",{icon:"bookmark",tooltip:"Anchor",onAction:t,onSetup:t=>{const o=e.selection.selectorChangedWithUnbind("a:not([href])",t.setActive).unbind,n=u(e)(t);return()=>{o(),n()}}}),e.ui.registry.addMenuItem("anchor",{icon:"bookmark",text:"Anchor...",onAction:t,onSetup:u(e)})})(e),e.on("PreInit",(()=>{(e=>{e.formatter.register("namedAnchor",{inline:"a",selector:a,remove:"all",split:!0,deep:!0,attributes:{id:"%value"},onmatch:(e,t,o)=>l(e)})})(e)}))}))}();
|
||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.dom.RangeUtils"),o=tinymce.util.Tools.resolve("tinymce.util.Tools");const n=e=>e.options.get("allow_html_in_named_anchor");const a="a:not([href])",r=e=>!e,i=e=>e.getAttribute("id")||e.getAttribute("name")||"",l=e=>(e=>"a"===e.nodeName.toLowerCase())(e)&&!e.getAttribute("href")&&""!==i(e),s=e=>e.dom.getParent(e.selection.getStart(),a),d=(e,a)=>{const r=s(e);r?((e,t,o)=>{o.removeAttribute("name"),o.id=t,e.addVisual(),e.undoManager.add()})(e,a,r):((e,a)=>{e.undoManager.transact(()=>{n(e)||e.selection.collapse(!0),e.selection.isCollapsed()?e.insertContent(e.dom.createHTML("a",{id:a})):((e=>{const n=e.dom;t(n).walk(e.selection.getRng(),e=>{o.each(e,e=>{var t;l(t=e)&&!t.firstChild&&n.remove(e,!1)})})})(e),e.formatter.remove("namedAnchor",void 0,void 0,!0),e.formatter.apply("namedAnchor",{value:a}),e.addVisual())})})(e,a),e.focus()},c=e=>(e=>r(e.attr("href"))&&!r(e.attr("id")||e.attr("name")))(e)&&!e.firstChild,m=e=>t=>{for(let o=0;o<t.length;o++){const n=t[o];c(n)&&n.attr("contenteditable",e)}},u=e=>t=>{const o=()=>{t.setEnabled(e.selection.isEditable())};return e.on("NodeChange",o),o(),()=>{e.off("NodeChange",o)}};e.add("anchor",e=>{(e=>{(0,e.options.register)("allow_html_in_named_anchor",{processor:"boolean",default:!1})})(e),(e=>{e.on("PreInit",()=>{e.parser.addNodeFilter("a",m("false")),e.serializer.addNodeFilter("a",m(null))})})(e),(e=>{e.addCommand("mceAnchor",()=>{(e=>{const t=(e=>{const t=s(e);return t?i(t):""})(e);e.windowManager.open({title:"Anchor",size:"normal",body:{type:"panel",items:[{name:"id",type:"input",label:"ID",placeholder:"example"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{id:t},onSubmit:t=>{((e,t)=>/^[A-Za-z][A-Za-z0-9\-:._]*$/.test(t)?(d(e,t),!0):(e.windowManager.alert("ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores."),!1))(e,t.getData().id)&&t.close()}})})(e)})})(e),(e=>{const t=()=>e.execCommand("mceAnchor");e.ui.registry.addToggleButton("anchor",{icon:"bookmark",tooltip:"Anchor",onAction:t,onSetup:t=>{const o=e.selection.selectorChangedWithUnbind("a:not([href])",t.setActive).unbind,n=u(e)(t);return()=>{o(),n()}}}),e.ui.registry.addMenuItem("anchor",{icon:"bookmark",text:"Anchor...",onAction:t,onSetup:u(e)})})(e),e.on("PreInit",()=>{(e=>{e.formatter.register("namedAnchor",{inline:"a",selector:a,remove:"all",split:!0,deep:!0,attributes:{id:"%value"},onmatch:(e,t,o)=>l(e)})})(e)})})}();
|
||||
@@ -0,0 +1,7 @@
|
||||
// Exports the "autolink" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/autolink')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/autolink'
|
||||
require('./plugin.js');
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-wrapper-object-types */
|
||||
const hasProto = (v, constructor, predicate) => {
|
||||
if (predicate(v, constructor.prototype)) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
// String-based fallback time
|
||||
return v.constructor?.name === constructor.name;
|
||||
}
|
||||
};
|
||||
const typeOf = (x) => {
|
||||
const t = typeof x;
|
||||
if (x === null) {
|
||||
return 'null';
|
||||
}
|
||||
else if (t === 'object' && Array.isArray(x)) {
|
||||
return 'array';
|
||||
}
|
||||
else if (t === 'object' && hasProto(x, String, (o, proto) => proto.isPrototypeOf(o))) {
|
||||
return 'string';
|
||||
}
|
||||
else {
|
||||
return t;
|
||||
}
|
||||
};
|
||||
const isType = (type) => (value) => typeOf(value) === type;
|
||||
const eq = (t) => (a) => t === a;
|
||||
const isString = isType('string');
|
||||
const isUndefined = eq(undefined);
|
||||
const isNullable = (a) => a === null || a === undefined;
|
||||
const isNonNullable = (a) => !isNullable(a);
|
||||
|
||||
const not = (f) => (t) => !f(t);
|
||||
|
||||
const hasOwnProperty = Object.hasOwnProperty;
|
||||
const has = (obj, key) => hasOwnProperty.call(obj, key);
|
||||
|
||||
const checkRange = (str, substr, start) => substr === '' || str.length >= substr.length && str.substr(start, start + substr.length) === substr;
|
||||
const contains = (str, substr, start = 0, end) => {
|
||||
const idx = str.indexOf(substr, start);
|
||||
if (idx !== -1) {
|
||||
return isUndefined(end) ? true : idx + substr.length <= end;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
/** Does 'str' start with 'prefix'?
|
||||
* Note: all strings start with the empty string.
|
||||
* More formally, for all strings x, startsWith(x, "").
|
||||
* This is so that for all strings x and y, startsWith(y + x, y)
|
||||
*/
|
||||
const startsWith = (str, prefix) => {
|
||||
return checkRange(str, prefix, 0);
|
||||
};
|
||||
|
||||
const zeroWidth = '\uFEFF';
|
||||
const isZwsp = (char) => char === zeroWidth;
|
||||
const removeZwsp = (s) => s.replace(/\uFEFF/g, '');
|
||||
|
||||
/*
|
||||
The RegEx parses the following components (https://www.rfc-editor.org/rfc/rfc3986.txt):
|
||||
|
||||
scheme:[//[user:password@]host[:port]][/]path[?query][#fragment]
|
||||
|
||||
foo://example.com:8042/over/there?name=ferret#nose
|
||||
\_/ \______________/\_________/ \_________/ \__/
|
||||
| | | | |
|
||||
scheme authority path query fragment
|
||||
|
||||
Originally from:
|
||||
http://blog.mattheworiordan.com/post/13174566389/url-regular-expression-for-links-with-or-without-the
|
||||
|
||||
Modified to:
|
||||
- include port numbers
|
||||
- allow full stops in email addresses
|
||||
- allow [-.~*+=!&;:'%@?^${}(),\/\w] after the #
|
||||
- allow [-.~*+=!&;:'%@?^${}(),\/\w] after the ?
|
||||
- move allow -_.~*+=!&;:'%@?^${}() in email usernames to the first @ match (TBIO-4809)
|
||||
- enforce domains to be [A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)* so they can't end in a period (TBIO-4809)
|
||||
- removed a bunch of escaping, made every group non-capturing (during TBIO-4809)
|
||||
- colons are only valid when followed directly by // or some text and then @ (TBIO-4867)
|
||||
- only include the fragment '#' if it has 1 or more trailing matches
|
||||
- only include the query '?' if it has 1 or more trailing matches
|
||||
- allow commas in URL path
|
||||
- exclude trailing comma and period in URL path
|
||||
- allow up to 15 character schemes including all valid characters from the spec https://url.spec.whatwg.org/#url-scheme-string (TINY-5074)
|
||||
- changed instances of 0-9 to be \d (TINY-5074)
|
||||
- reduced duplication (TINY-5074)
|
||||
- allow [*!;:'@$] in the path segment as they are valid characters per the spec: https://url.spec.whatwg.org/#url-path-segment-string (TINY-8069)
|
||||
|
||||
(?:
|
||||
(?:
|
||||
[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?
|
||||
| www\.
|
||||
| [-;:&=+$,.\w]+@
|
||||
)
|
||||
[A-Za-z\d-]+
|
||||
(?:\.[A-Za-z\d-]+)*
|
||||
)
|
||||
(?::\d+)?
|
||||
(?:
|
||||
\/
|
||||
(?:
|
||||
[-.~*+=!;:'%@$(),\/\w]*[-~*+=%@$()\/\w]
|
||||
)?
|
||||
)?
|
||||
(?:
|
||||
\?
|
||||
(?:
|
||||
[-.~*+=!&;:'%@?^${}(),\/\w]+
|
||||
)
|
||||
)?
|
||||
(?:
|
||||
#
|
||||
(?:
|
||||
[-.~*+=!&;:'%@?^${}(),\/\w]+
|
||||
)
|
||||
)?
|
||||
*/
|
||||
const link = () =>
|
||||
// eslint-disable-next-line max-len
|
||||
/(?:[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?|www\.|[-;:&=+$,.\w]+@)[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)*(?::\d+)?(?:\/(?:[-.~*+=!;:'%@$(),\/\w]*[-~*+=%@$()\/\w])?)?(?:\?(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?(?:#(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?/g;
|
||||
|
||||
const option = (name) => (editor) => editor.options.get(name);
|
||||
const register = (editor) => {
|
||||
const registerOption = editor.options.register;
|
||||
registerOption('autolink_pattern', {
|
||||
processor: 'regexp',
|
||||
// Use the Polaris link detection, however for autolink we need to make it be an exact match
|
||||
default: new RegExp('^' + link().source + '$', 'i')
|
||||
});
|
||||
registerOption('link_default_target', {
|
||||
processor: 'string'
|
||||
});
|
||||
registerOption('link_default_protocol', {
|
||||
processor: 'string',
|
||||
default: 'https'
|
||||
});
|
||||
};
|
||||
const getAutoLinkPattern = option('autolink_pattern');
|
||||
const getDefaultLinkTarget = option('link_default_target');
|
||||
const getDefaultLinkProtocol = option('link_default_protocol');
|
||||
const allowUnsafeLinkTarget = option('allow_unsafe_link_target');
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.dom.TextSeeker');
|
||||
|
||||
const isTextNode = (node) => node.nodeType === 3;
|
||||
const isElement = (node) => node.nodeType === 1;
|
||||
const isBracketOrSpace = (char) => /^[(\[{ \u00a0]$/.test(char);
|
||||
// Note: This is similar to the Polaris protocol detection, except it also handles `mailto` and any length scheme
|
||||
const hasProtocol = (url) => /^([A-Za-z][A-Za-z\d.+-]*:\/\/)|mailto:/.test(url);
|
||||
// A limited list of punctuation characters that might be used after a link
|
||||
const isPunctuation = (char) => /[?!,.;:]/.test(char);
|
||||
const findChar = (text, index, predicate) => {
|
||||
for (let i = index - 1; i >= 0; i--) {
|
||||
const char = text.charAt(i);
|
||||
if (!isZwsp(char) && predicate(char)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
const freefallRtl = (container, offset) => {
|
||||
let tempNode = container;
|
||||
let tempOffset = offset;
|
||||
while (isElement(tempNode) && tempNode.childNodes[tempOffset]) {
|
||||
tempNode = tempNode.childNodes[tempOffset];
|
||||
tempOffset = isTextNode(tempNode) ? tempNode.data.length : tempNode.childNodes.length;
|
||||
}
|
||||
return { container: tempNode, offset: tempOffset };
|
||||
};
|
||||
|
||||
const parseCurrentLine = (editor, offset) => {
|
||||
const voidElements = editor.schema.getVoidElements();
|
||||
const autoLinkPattern = getAutoLinkPattern(editor);
|
||||
const { dom, selection } = editor;
|
||||
// Never create a link when we are inside a link
|
||||
if (dom.getParent(selection.getNode(), 'a[href]') !== null || editor.mode.isReadOnly()) {
|
||||
return null;
|
||||
}
|
||||
const rng = selection.getRng();
|
||||
const textSeeker = global(dom, (node) => {
|
||||
return dom.isBlock(node) || has(voidElements, node.nodeName.toLowerCase()) || dom.getContentEditable(node) === 'false' || dom.getParent(node, 'a[href]') !== null;
|
||||
});
|
||||
// Descend down the end container to find the text node
|
||||
const { container: endContainer, offset: endOffset } = freefallRtl(rng.endContainer, rng.endOffset);
|
||||
// Find the root container to use when walking
|
||||
const root = dom.getParent(endContainer, dom.isBlock) ?? dom.getRoot();
|
||||
// Move the selection backwards to the start of the potential URL to account for the pressed character
|
||||
// while also excluding the last full stop from a word like "www.site.com."
|
||||
const endSpot = textSeeker.backwards(endContainer, endOffset + offset, (node, offset) => {
|
||||
const text = node.data;
|
||||
const idx = findChar(text, offset, not(isBracketOrSpace));
|
||||
// Move forward one so the offset is after the found character unless the found char is a punctuation char
|
||||
return idx === -1 || isPunctuation(text[idx]) ? idx : idx + 1;
|
||||
}, root);
|
||||
if (!endSpot) {
|
||||
return null;
|
||||
}
|
||||
// Walk backwards until we find a boundary or a bracket/space
|
||||
let lastTextNode = endSpot.container;
|
||||
const startSpot = textSeeker.backwards(endSpot.container, endSpot.offset, (node, offset) => {
|
||||
lastTextNode = node;
|
||||
const idx = findChar(node.data, offset, isBracketOrSpace);
|
||||
// Move forward one so that the offset is after the bracket/space
|
||||
return idx === -1 ? idx : idx + 1;
|
||||
}, root);
|
||||
const newRng = dom.createRng();
|
||||
if (!startSpot) {
|
||||
newRng.setStart(lastTextNode, 0);
|
||||
}
|
||||
else {
|
||||
newRng.setStart(startSpot.container, startSpot.offset);
|
||||
}
|
||||
newRng.setEnd(endSpot.container, endSpot.offset);
|
||||
const rngText = removeZwsp(newRng.toString());
|
||||
const matches = rngText.match(autoLinkPattern);
|
||||
if (matches) {
|
||||
let url = matches[0];
|
||||
if (startsWith(url, 'www.')) {
|
||||
const protocol = getDefaultLinkProtocol(editor);
|
||||
url = protocol + '://' + url;
|
||||
}
|
||||
else if (contains(url, '@') && !hasProtocol(url)) {
|
||||
url = 'mailto:' + url;
|
||||
}
|
||||
return { rng: newRng, url };
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const convertToLink = (editor, result) => {
|
||||
const { dom, selection } = editor;
|
||||
const { rng, url } = result;
|
||||
const bookmark = selection.getBookmark();
|
||||
selection.setRng(rng);
|
||||
// Needs to be a native createlink command since this is executed in a keypress event handler
|
||||
// so the pending character that is to be inserted needs to be inserted after the link. That will not
|
||||
// happen if we use the formatter create link version. Since we're using the native command
|
||||
// then we also need to ensure the exec command events are fired for backwards compatibility.
|
||||
const command = 'createlink';
|
||||
const args = { command, ui: false, value: url };
|
||||
const beforeExecEvent = editor.dispatch('BeforeExecCommand', args);
|
||||
if (!beforeExecEvent.isDefaultPrevented()) {
|
||||
editor.getDoc().execCommand(command, false, url);
|
||||
editor.dispatch('ExecCommand', args);
|
||||
const defaultLinkTarget = getDefaultLinkTarget(editor);
|
||||
if (isString(defaultLinkTarget)) {
|
||||
const anchor = selection.getNode();
|
||||
dom.setAttrib(anchor, 'target', defaultLinkTarget);
|
||||
// Ensure noopener is added for blank targets to prevent window opener attacks
|
||||
if (defaultLinkTarget === '_blank' && !allowUnsafeLinkTarget(editor)) {
|
||||
dom.setAttrib(anchor, 'rel', 'noopener');
|
||||
}
|
||||
}
|
||||
}
|
||||
selection.moveToBookmark(bookmark);
|
||||
editor.nodeChanged();
|
||||
};
|
||||
const handleSpacebar = (editor) => {
|
||||
const result = parseCurrentLine(editor, -1);
|
||||
if (isNonNullable(result)) {
|
||||
convertToLink(editor, result);
|
||||
}
|
||||
};
|
||||
const handleBracket = handleSpacebar;
|
||||
const handleEnter = (editor) => {
|
||||
const result = parseCurrentLine(editor, 0);
|
||||
if (isNonNullable(result)) {
|
||||
convertToLink(editor, result);
|
||||
}
|
||||
};
|
||||
const setup = (editor) => {
|
||||
editor.on('keydown', (e) => {
|
||||
if (e.keyCode === 13 && !e.isDefaultPrevented()) {
|
||||
handleEnter(editor);
|
||||
}
|
||||
});
|
||||
editor.on('keyup', (e) => {
|
||||
if (e.keyCode === 32) {
|
||||
handleSpacebar(editor);
|
||||
// One of the closing bracket keys: ), ] or }
|
||||
}
|
||||
else if (e.keyCode === 48 && e.shiftKey || e.keyCode === 221) {
|
||||
handleBracket(editor);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global$1.add('autolink', (editor) => {
|
||||
register(editor);
|
||||
setup(editor);
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
+1
-4
@@ -1,4 +1 @@
|
||||
/**
|
||||
* TinyMCE version 7.3.0 (2024-08-07)
|
||||
*/
|
||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>t=>t.options.get(e),n=t("autolink_pattern"),o=t("link_default_target"),r=t("link_default_protocol"),a=t("allow_unsafe_link_target"),s=("string",e=>"string"===(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(n=o=e,(r=String).prototype.isPrototypeOf(n)||(null===(a=o.constructor)||void 0===a?void 0:a.name)===r.name)?"string":t;var n,o,r,a})(e));const l=(void 0,e=>undefined===e);const i=e=>!(e=>null==e)(e),c=Object.hasOwnProperty,d=e=>"\ufeff"===e;var u=tinymce.util.Tools.resolve("tinymce.dom.TextSeeker");const f=e=>/^[(\[{ \u00a0]$/.test(e),g=(e,t,n)=>{for(let o=t-1;o>=0;o--){const t=e.charAt(o);if(!d(t)&&n(t))return o}return-1},m=(e,t)=>{var o;const a=e.schema.getVoidElements(),s=n(e),{dom:i,selection:d}=e;if(null!==i.getParent(d.getNode(),"a[href]"))return null;const m=d.getRng(),k=u(i,(e=>{return i.isBlock(e)||(t=a,n=e.nodeName.toLowerCase(),c.call(t,n))||"false"===i.getContentEditable(e);var t,n})),{container:p,offset:y}=((e,t)=>{let n=e,o=t;for(;1===n.nodeType&&n.childNodes[o];)n=n.childNodes[o],o=3===n.nodeType?n.data.length:n.childNodes.length;return{container:n,offset:o}})(m.endContainer,m.endOffset),w=null!==(o=i.getParent(p,i.isBlock))&&void 0!==o?o:i.getRoot(),h=k.backwards(p,y+t,((e,t)=>{const n=e.data,o=g(n,t,(r=f,e=>!r(e)));var r,a;return-1===o||(a=n[o],/[?!,.;:]/.test(a))?o:o+1}),w);if(!h)return null;let v=h.container;const _=k.backwards(h.container,h.offset,((e,t)=>{v=e;const n=g(e.data,t,f);return-1===n?n:n+1}),w),A=i.createRng();_?A.setStart(_.container,_.offset):A.setStart(v,0),A.setEnd(h.container,h.offset);const C=A.toString().replace(/\uFEFF/g,"").match(s);if(C){let t=C[0];return $="www.",(b=t).length>=4&&b.substr(0,4)===$?t=r(e)+"://"+t:((e,t,n=0,o)=>{const r=e.indexOf(t,n);return-1!==r&&(!!l(o)||r+t.length<=o)})(t,"@")&&!(e=>/^([A-Za-z][A-Za-z\d.+-]*:\/\/)|mailto:/.test(e))(t)&&(t="mailto:"+t),{rng:A,url:t}}var b,$;return null},k=(e,t)=>{const{dom:n,selection:r}=e,{rng:l,url:i}=t,c=r.getBookmark();r.setRng(l);const d="createlink",u={command:d,ui:!1,value:i};if(!e.dispatch("BeforeExecCommand",u).isDefaultPrevented()){e.getDoc().execCommand(d,!1,i),e.dispatch("ExecCommand",u);const t=o(e);if(s(t)){const o=r.getNode();n.setAttrib(o,"target",t),"_blank"!==t||a(e)||n.setAttrib(o,"rel","noopener")}}r.moveToBookmark(c),e.nodeChanged()},p=e=>{const t=m(e,-1);i(t)&&k(e,t)},y=p;e.add("autolink",(e=>{(e=>{const t=e.options.register;t("autolink_pattern",{processor:"regexp",default:new RegExp("^"+/(?:[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?|www\.|[-;:&=+$,.\w]+@)[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)*(?::\d+)?(?:\/(?:[-.~*+=!;:'%@$(),\/\w]*[-~*+=%@$()\/\w])?)?(?:\?(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?(?:#(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?/g.source+"$","i")}),t("link_default_target",{processor:"string"}),t("link_default_protocol",{processor:"string",default:"https"})})(e),(e=>{e.on("keydown",(t=>{13!==t.keyCode||t.isDefaultPrevented()||(e=>{const t=m(e,0);i(t)&&k(e,t)})(e)})),e.on("keyup",(t=>{32===t.keyCode?p(e):(48===t.keyCode&&t.shiftKey||221===t.keyCode)&&y(e)}))})(e)}))}();
|
||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>"string"===(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(n=o=e,(r=String).prototype.isPrototypeOf(n)||o.constructor?.name===r.name)?"string":t;var n,o,r})(e);const n=e=>undefined===e;const o=e=>!(e=>null==e)(e),r=Object.hasOwnProperty,a=e=>"\ufeff"===e,s=e=>t=>t.options.get(e),l=s("autolink_pattern"),c=s("link_default_target"),i=s("link_default_protocol"),d=s("allow_unsafe_link_target");var u=tinymce.util.Tools.resolve("tinymce.dom.TextSeeker");const f=e=>3===e.nodeType,g=e=>1===e.nodeType,m=e=>/^[(\[{ \u00a0]$/.test(e),k=(e,t,n)=>{for(let o=t-1;o>=0;o--){const t=e.charAt(o);if(!a(t)&&n(t))return o}return-1},p=(e,t)=>{const o=e.schema.getVoidElements(),a=l(e),{dom:s,selection:c}=e;if(null!==s.getParent(c.getNode(),"a[href]")||e.mode.isReadOnly())return null;const d=c.getRng(),p=u(s,e=>{return s.isBlock(e)||(t=o,n=e.nodeName.toLowerCase(),r.call(t,n))||"false"===s.getContentEditable(e)||null!==s.getParent(e,"a[href]");var t,n}),{container:y,offset:w}=((e,t)=>{let n=e,o=t;for(;g(n)&&n.childNodes[o];)n=n.childNodes[o],o=f(n)?n.data.length:n.childNodes.length;return{container:n,offset:o}})(d.endContainer,d.endOffset),h=s.getParent(y,s.isBlock)??s.getRoot(),_=p.backwards(y,w+t,(e,t)=>{const n=e.data,o=k(n,t,(r=m,e=>!r(e)));var r,a;return-1===o||(a=n[o],/[?!,.;:]/.test(a))?o:o+1},h);if(!_)return null;let v=_.container;const A=p.backwards(_.container,_.offset,(e,t)=>{v=e;const n=k(e.data,t,m);return-1===n?n:n+1},h),C=s.createRng();A?C.setStart(A.container,A.offset):C.setStart(v,0),C.setEnd(_.container,_.offset);const b=C.toString().replace(/\uFEFF/g,"").match(a);if(b){let t=b[0];return $="www.",(P=t).length>=4&&P.substr(0,4)===$?t=i(e)+"://"+t:((e,t,o=0,r)=>{const a=e.indexOf(t,o);return-1!==a&&(!!n(r)||a+t.length<=r)})(t,"@")&&!(e=>/^([A-Za-z][A-Za-z\d.+-]*:\/\/)|mailto:/.test(e))(t)&&(t="mailto:"+t),{rng:C,url:t}}var P,$;return null},y=(e,n)=>{const{dom:o,selection:r}=e,{rng:a,url:s}=n,l=r.getBookmark();r.setRng(a);const i="createlink",u={command:i,ui:!1,value:s};if(!e.dispatch("BeforeExecCommand",u).isDefaultPrevented()){e.getDoc().execCommand(i,!1,s),e.dispatch("ExecCommand",u);const n=c(e);if(t(n)){const t=r.getNode();o.setAttrib(t,"target",n),"_blank"!==n||d(e)||o.setAttrib(t,"rel","noopener")}}r.moveToBookmark(l),e.nodeChanged()},w=e=>{const t=p(e,-1);o(t)&&y(e,t)},h=w;e.add("autolink",e=>{(e=>{const t=e.options.register;t("autolink_pattern",{processor:"regexp",default:new RegExp("^"+/(?:[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?|www\.|[-;:&=+$,.\w]+@)[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)*(?::\d+)?(?:\/(?:[-.~*+=!;:'%@$(),\/\w]*[-~*+=%@$()\/\w])?)?(?:\?(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?(?:#(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?/g.source+"$","i")}),t("link_default_target",{processor:"string"}),t("link_default_protocol",{processor:"string",default:"https"})})(e),(e=>{e.on("keydown",t=>{13!==t.keyCode||t.isDefaultPrevented()||(e=>{const t=p(e,0);o(t)&&y(e,t)})(e)}),e.on("keyup",t=>{32===t.keyCode?w(e):(48===t.keyCode&&t.shiftKey||221===t.keyCode)&&h(e)})})(e)})}();
|
||||
@@ -0,0 +1,7 @@
|
||||
// Exports the "autoresize" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/autoresize')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/autoresize'
|
||||
require('./plugin.js');
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const Cell = (initial) => {
|
||||
let value = initial;
|
||||
const get = () => {
|
||||
return value;
|
||||
};
|
||||
const set = (v) => {
|
||||
value = v;
|
||||
};
|
||||
return {
|
||||
get,
|
||||
set
|
||||
};
|
||||
};
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.Env');
|
||||
|
||||
const fireResizeEditor = (editor) => editor.dispatch('ResizeEditor');
|
||||
|
||||
const option = (name) => (editor) => editor.options.get(name);
|
||||
const register$1 = (editor) => {
|
||||
const registerOption = editor.options.register;
|
||||
registerOption('autoresize_overflow_padding', {
|
||||
processor: 'number',
|
||||
default: 1
|
||||
});
|
||||
registerOption('autoresize_bottom_margin', {
|
||||
processor: 'number',
|
||||
default: 50
|
||||
});
|
||||
};
|
||||
const getMinHeight = option('min_height');
|
||||
const getMaxHeight = option('max_height');
|
||||
const getAutoResizeOverflowPadding = option('autoresize_overflow_padding');
|
||||
const getAutoResizeBottomMargin = option('autoresize_bottom_margin');
|
||||
|
||||
/**
|
||||
* This class contains all core logic for the autoresize plugin.
|
||||
*
|
||||
* @class tinymce.autoresize.Plugin
|
||||
* @private
|
||||
*/
|
||||
const isFullscreen = (editor) => editor.plugins.fullscreen && editor.plugins.fullscreen.isFullscreen();
|
||||
const toggleScrolling = (editor, state) => {
|
||||
const body = editor.getBody();
|
||||
if (body) {
|
||||
body.style.overflowY = state ? '' : 'hidden';
|
||||
if (!state) {
|
||||
body.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
const parseCssValueToInt = (dom, elm, name, computed) => {
|
||||
const value = parseInt(dom.getStyle(elm, name, computed) ?? '', 10);
|
||||
// The value maybe be an empty string, so in that case treat it as being 0
|
||||
return isNaN(value) ? 0 : value;
|
||||
};
|
||||
const shouldScrollIntoView = (trigger) => {
|
||||
// Only scroll the selection into view when we're inserting content. Any other
|
||||
// triggers the selection should already be in view and resizing would only
|
||||
// extend the content area.
|
||||
if (trigger?.type.toLowerCase() === 'setcontent') {
|
||||
const setContentEvent = trigger;
|
||||
return setContentEvent.selection === true || setContentEvent.paste === true;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
/**
|
||||
* This method gets executed each time the editor needs to resize.
|
||||
*/
|
||||
const resize = (editor, oldSize, trigger, getExtraMarginBottom) => {
|
||||
const dom = editor.dom;
|
||||
const doc = editor.getDoc();
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
if (isFullscreen(editor)) {
|
||||
toggleScrolling(editor, true);
|
||||
return;
|
||||
}
|
||||
const docEle = doc.documentElement;
|
||||
const resizeBottomMargin = getExtraMarginBottom ? getExtraMarginBottom() : getAutoResizeOverflowPadding(editor);
|
||||
const minHeight = getMinHeight(editor) ?? editor.getElement().offsetHeight;
|
||||
let resizeHeight = minHeight;
|
||||
// Calculate outer height of the doc element using CSS styles
|
||||
const marginTop = parseCssValueToInt(dom, docEle, 'margin-top', true);
|
||||
const marginBottom = parseCssValueToInt(dom, docEle, 'margin-bottom', true);
|
||||
let contentHeight = docEle.offsetHeight + marginTop + marginBottom + resizeBottomMargin;
|
||||
// Make sure we have a valid height
|
||||
if (contentHeight < 0) {
|
||||
contentHeight = 0;
|
||||
}
|
||||
// Determine the size of the chroming (menubar, toolbar, etc...)
|
||||
const containerHeight = editor.getContainer().offsetHeight;
|
||||
const contentAreaHeight = editor.getContentAreaContainer().offsetHeight;
|
||||
const chromeHeight = containerHeight - contentAreaHeight;
|
||||
// Don't make it smaller than the minimum height
|
||||
if (contentHeight + chromeHeight > minHeight) {
|
||||
resizeHeight = contentHeight + chromeHeight;
|
||||
}
|
||||
// If a maximum height has been defined don't exceed this height
|
||||
const maxHeight = getMaxHeight(editor);
|
||||
if (maxHeight && resizeHeight > maxHeight) {
|
||||
resizeHeight = maxHeight;
|
||||
toggleScrolling(editor, true);
|
||||
}
|
||||
else {
|
||||
toggleScrolling(editor, false);
|
||||
}
|
||||
const old = oldSize.get();
|
||||
if (old.set) {
|
||||
editor.dom.setStyles(editor.getDoc().documentElement, { 'min-height': 0 });
|
||||
editor.dom.setStyles(editor.getBody(), { 'min-height': 'inherit' });
|
||||
}
|
||||
// Resize content element
|
||||
if (resizeHeight !== old.totalHeight && (contentHeight - resizeBottomMargin !== old.contentHeight || !old.set)) {
|
||||
const deltaSize = (resizeHeight - old.totalHeight);
|
||||
dom.setStyle(editor.getContainer(), 'height', resizeHeight + 'px');
|
||||
oldSize.set({
|
||||
totalHeight: resizeHeight,
|
||||
contentHeight,
|
||||
set: true,
|
||||
});
|
||||
fireResizeEditor(editor);
|
||||
// iPadOS has an issue where it won't rerender the body when the iframe is resized
|
||||
// however if we reset the scroll position then it re-renders correctly
|
||||
if (global.browser.isSafari() && (global.os.isMacOS() || global.os.isiOS())) {
|
||||
const win = editor.getWin();
|
||||
win.scrollTo(win.pageXOffset, win.pageYOffset);
|
||||
}
|
||||
// Ensure the selection is in view, as it's potentially out of view after inserting content into the editor
|
||||
if (editor.hasFocus() && shouldScrollIntoView(trigger)) {
|
||||
editor.selection.scrollIntoView();
|
||||
}
|
||||
// WebKit doesn't decrease the size of the body element until the iframe gets resized
|
||||
// So we need to continue to resize the iframe down until the size gets fixed
|
||||
if ((global.browser.isSafari() || global.browser.isChromium()) && deltaSize < 0) {
|
||||
resize(editor, oldSize, trigger, getExtraMarginBottom);
|
||||
}
|
||||
}
|
||||
};
|
||||
const setup = (editor, oldSize) => {
|
||||
const getExtraMarginBottom = () => getAutoResizeBottomMargin(editor);
|
||||
editor.on('init', (e) => {
|
||||
const overflowPadding = getAutoResizeOverflowPadding(editor);
|
||||
const dom = editor.dom;
|
||||
// Disable height 100% on the root document element otherwise we'll end up resizing indefinitely
|
||||
dom.setStyles(editor.getDoc().documentElement, {
|
||||
height: 'auto'
|
||||
});
|
||||
if (global.browser.isEdge() || global.browser.isIE()) {
|
||||
dom.setStyles(editor.getBody(), {
|
||||
'paddingLeft': overflowPadding,
|
||||
'paddingRight': overflowPadding,
|
||||
// IE & Edge have a min height of 150px by default on the body, so override that
|
||||
'min-height': 0
|
||||
});
|
||||
}
|
||||
else {
|
||||
dom.setStyles(editor.getBody(), {
|
||||
paddingLeft: overflowPadding,
|
||||
paddingRight: overflowPadding
|
||||
});
|
||||
}
|
||||
resize(editor, oldSize, e, getExtraMarginBottom);
|
||||
});
|
||||
editor.on('NodeChange SetContent keyup FullscreenStateChanged ResizeContent', (e) => {
|
||||
resize(editor, oldSize, e, getExtraMarginBottom);
|
||||
});
|
||||
};
|
||||
|
||||
const register = (editor, oldSize) => {
|
||||
editor.addCommand('mceAutoResize', () => {
|
||||
resize(editor, oldSize);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* This class contains all core logic for the autoresize plugin.
|
||||
*
|
||||
* @class tinymce.autoresize.Plugin
|
||||
* @private
|
||||
*/
|
||||
var Plugin = () => {
|
||||
global$1.add('autoresize', (editor) => {
|
||||
register$1(editor);
|
||||
// If autoresize is enabled, disable resize if the user hasn't explicitly enabled it
|
||||
// TINY-8288: This currently does nothing because of a bug in the theme
|
||||
if (!editor.options.isSet('resize')) {
|
||||
editor.options.set('resize', false);
|
||||
}
|
||||
if (!editor.inline) {
|
||||
const oldSize = Cell({
|
||||
totalHeight: 0,
|
||||
contentHeight: 0,
|
||||
set: false,
|
||||
});
|
||||
register(editor, oldSize);
|
||||
setup(editor, oldSize);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
+1
-4
@@ -1,4 +1 @@
|
||||
/**
|
||||
* TinyMCE version 7.3.0 (2024-08-07)
|
||||
*/
|
||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.Env");const o=e=>t=>t.options.get(e),n=o("min_height"),s=o("max_height"),i=o("autoresize_overflow_padding"),r=o("autoresize_bottom_margin"),g=(e,t)=>{const o=e.getBody();o&&(o.style.overflowY=t?"":"hidden",t||(o.scrollTop=0))},l=(e,t,o,n)=>{var s;const i=parseInt(null!==(s=e.getStyle(t,o,n))&&void 0!==s?s:"",10);return isNaN(i)?0:i},a=(e,o,r,c)=>{var d;const u=e.dom,h=e.getDoc();if(!h)return;if((e=>e.plugins.fullscreen&&e.plugins.fullscreen.isFullscreen())(e))return void g(e,!0);const m=h.documentElement,f=c?c():i(e),p=null!==(d=n(e))&&void 0!==d?d:e.getElement().offsetHeight;let y=p;const S=l(u,m,"margin-top",!0),v=l(u,m,"margin-bottom",!0);let C=m.offsetHeight+S+v+f;C<0&&(C=0);const H=e.getContainer().offsetHeight-e.getContentAreaContainer().offsetHeight;C+H>p&&(y=C+H);const b=s(e);b&&y>b?(y=b,g(e,!0)):g(e,!1);const w=o.get();if(w.set&&(e.dom.setStyles(e.getDoc().documentElement,{"min-height":0}),e.dom.setStyles(e.getBody(),{"min-height":"inherit"})),y!==w.totalHeight&&(C-f!==w.contentHeight||!w.set)){const n=y-w.totalHeight;if(u.setStyle(e.getContainer(),"height",y+"px"),o.set({totalHeight:y,contentHeight:C,set:!0}),(e=>{e.dispatch("ResizeEditor")})(e),t.browser.isSafari()&&(t.os.isMacOS()||t.os.isiOS())){const t=e.getWin();t.scrollTo(t.pageXOffset,t.pageYOffset)}e.hasFocus()&&(e=>{if("setcontent"===(null==e?void 0:e.type.toLowerCase())){const t=e;return!0===t.selection||!0===t.paste}return!1})(r)&&e.selection.scrollIntoView(),(t.browser.isSafari()||t.browser.isChromium())&&n<0&&a(e,o,r,c)}};e.add("autoresize",(e=>{if((e=>{const t=e.options.register;t("autoresize_overflow_padding",{processor:"number",default:1}),t("autoresize_bottom_margin",{processor:"number",default:50})})(e),e.options.isSet("resize")||e.options.set("resize",!1),!e.inline){const o=(e=>{let t={totalHeight:0,contentHeight:0,set:!1};return{get:()=>t,set:e=>{t=e}}})();((e,t)=>{e.addCommand("mceAutoResize",(()=>{a(e,t)}))})(e,o),((e,o)=>{const n=()=>r(e);e.on("init",(s=>{const r=i(e),g=e.dom;g.setStyles(e.getDoc().documentElement,{height:"auto"}),t.browser.isEdge()||t.browser.isIE()?g.setStyles(e.getBody(),{paddingLeft:r,paddingRight:r,"min-height":0}):g.setStyles(e.getBody(),{paddingLeft:r,paddingRight:r}),a(e,o,s,n)})),e.on("NodeChange SetContent keyup FullscreenStateChanged ResizeContent",(t=>{a(e,o,t,n)}))})(e,o)}}))}();
|
||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.Env");const o=e=>t=>t.options.get(e),s=o("min_height"),n=o("max_height"),i=o("autoresize_overflow_padding"),r=o("autoresize_bottom_margin"),g=(e,t)=>{const o=e.getBody();o&&(o.style.overflowY=t?"":"hidden",t||(o.scrollTop=0))},l=(e,t,o,s)=>{const n=parseInt(e.getStyle(t,o,s)??"",10);return isNaN(n)?0:n},a=(e,o,r,c)=>{const d=e.dom,h=e.getDoc();if(!h)return;if((e=>e.plugins.fullscreen&&e.plugins.fullscreen.isFullscreen())(e))return void g(e,!0);const m=h.documentElement,u=c?c():i(e),f=s(e)??e.getElement().offsetHeight;let p=f;const y=l(d,m,"margin-top",!0),S=l(d,m,"margin-bottom",!0);let C=m.offsetHeight+y+S+u;C<0&&(C=0);const H=e.getContainer().offsetHeight-e.getContentAreaContainer().offsetHeight;C+H>f&&(p=C+H);const b=n(e);b&&p>b?(p=b,g(e,!0)):g(e,!1);const w=o.get();if(w.set&&(e.dom.setStyles(e.getDoc().documentElement,{"min-height":0}),e.dom.setStyles(e.getBody(),{"min-height":"inherit"})),p!==w.totalHeight&&(C-u!==w.contentHeight||!w.set)){const s=p-w.totalHeight;if(d.setStyle(e.getContainer(),"height",p+"px"),o.set({totalHeight:p,contentHeight:C,set:!0}),(e=>{e.dispatch("ResizeEditor")})(e),t.browser.isSafari()&&(t.os.isMacOS()||t.os.isiOS())){const t=e.getWin();t.scrollTo(t.pageXOffset,t.pageYOffset)}e.hasFocus()&&(e=>{if("setcontent"===e?.type.toLowerCase()){const t=e;return!0===t.selection||!0===t.paste}return!1})(r)&&e.selection.scrollIntoView(),(t.browser.isSafari()||t.browser.isChromium())&&s<0&&a(e,o,r,c)}};e.add("autoresize",e=>{if((e=>{const t=e.options.register;t("autoresize_overflow_padding",{processor:"number",default:1}),t("autoresize_bottom_margin",{processor:"number",default:50})})(e),e.options.isSet("resize")||e.options.set("resize",!1),!e.inline){const o=(()=>{let e={totalHeight:0,contentHeight:0,set:!1};return{get:()=>e,set:t=>{e=t}}})();((e,t)=>{e.addCommand("mceAutoResize",()=>{a(e,t)})})(e,o),((e,o)=>{const s=()=>r(e);e.on("init",n=>{const r=i(e),g=e.dom;g.setStyles(e.getDoc().documentElement,{height:"auto"}),t.browser.isEdge()||t.browser.isIE()?g.setStyles(e.getBody(),{paddingLeft:r,paddingRight:r,"min-height":0}):g.setStyles(e.getBody(),{paddingLeft:r,paddingRight:r}),a(e,o,n,s)}),e.on("NodeChange SetContent keyup FullscreenStateChanged ResizeContent",t=>{a(e,o,t,s)})})(e,o)}})}();
|
||||
@@ -0,0 +1,7 @@
|
||||
// Exports the "autosave" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/autosave')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/autosave'
|
||||
require('./plugin.js');
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global$4 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-wrapper-object-types */
|
||||
const hasProto = (v, constructor, predicate) => {
|
||||
if (predicate(v, constructor.prototype)) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
// String-based fallback time
|
||||
return v.constructor?.name === constructor.name;
|
||||
}
|
||||
};
|
||||
const typeOf = (x) => {
|
||||
const t = typeof x;
|
||||
if (x === null) {
|
||||
return 'null';
|
||||
}
|
||||
else if (t === 'object' && Array.isArray(x)) {
|
||||
return 'array';
|
||||
}
|
||||
else if (t === 'object' && hasProto(x, String, (o, proto) => proto.isPrototypeOf(o))) {
|
||||
return 'string';
|
||||
}
|
||||
else {
|
||||
return t;
|
||||
}
|
||||
};
|
||||
const isType = (type) => (value) => typeOf(value) === type;
|
||||
const eq = (t) => (a) => t === a;
|
||||
const isString = isType('string');
|
||||
const isUndefined = eq(undefined);
|
||||
|
||||
var global$3 = tinymce.util.Tools.resolve('tinymce.util.Delay');
|
||||
|
||||
var global$2 = tinymce.util.Tools.resolve('tinymce.util.LocalStorage');
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.util.Tools');
|
||||
|
||||
const fireRestoreDraft = (editor) => editor.dispatch('RestoreDraft');
|
||||
const fireStoreDraft = (editor) => editor.dispatch('StoreDraft');
|
||||
const fireRemoveDraft = (editor) => editor.dispatch('RemoveDraft');
|
||||
|
||||
const parse = (timeString) => {
|
||||
const multiples = {
|
||||
s: 1000,
|
||||
m: 60000
|
||||
};
|
||||
const parsedTime = /^(\d+)([ms]?)$/.exec(timeString);
|
||||
return (parsedTime && parsedTime[2] ? multiples[parsedTime[2]] : 1) * parseInt(timeString, 10);
|
||||
};
|
||||
|
||||
const option = (name) => (editor) => editor.options.get(name);
|
||||
const register$1 = (editor) => {
|
||||
const registerOption = editor.options.register;
|
||||
const timeProcessor = (value) => {
|
||||
const valid = isString(value);
|
||||
if (valid) {
|
||||
return { value: parse(value), valid };
|
||||
}
|
||||
else {
|
||||
return { valid: false, message: 'Must be a string.' };
|
||||
}
|
||||
};
|
||||
registerOption('autosave_ask_before_unload', {
|
||||
processor: 'boolean',
|
||||
default: true
|
||||
});
|
||||
registerOption('autosave_prefix', {
|
||||
processor: 'string',
|
||||
default: 'tinymce-autosave-{path}{query}{hash}-{id}-'
|
||||
});
|
||||
registerOption('autosave_restore_when_empty', {
|
||||
processor: 'boolean',
|
||||
default: false
|
||||
});
|
||||
registerOption('autosave_interval', {
|
||||
processor: timeProcessor,
|
||||
default: '30s'
|
||||
});
|
||||
registerOption('autosave_retention', {
|
||||
processor: timeProcessor,
|
||||
default: '20m'
|
||||
});
|
||||
};
|
||||
const shouldAskBeforeUnload = option('autosave_ask_before_unload');
|
||||
const shouldRestoreWhenEmpty = option('autosave_restore_when_empty');
|
||||
const getAutoSaveInterval = option('autosave_interval');
|
||||
const getAutoSaveRetention = option('autosave_retention');
|
||||
const getAutoSavePrefix = (editor) => {
|
||||
const location = document.location;
|
||||
return editor.options.get('autosave_prefix').replace(/{path}/g, location.pathname)
|
||||
.replace(/{query}/g, location.search)
|
||||
.replace(/{hash}/g, location.hash)
|
||||
.replace(/{id}/g, editor.id);
|
||||
};
|
||||
|
||||
const isEmpty = (editor, html) => {
|
||||
if (isUndefined(html)) {
|
||||
return editor.dom.isEmpty(editor.getBody());
|
||||
}
|
||||
else {
|
||||
const trimmedHtml = global$1.trim(html);
|
||||
if (trimmedHtml === '') {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
const fragment = new DOMParser().parseFromString(trimmedHtml, 'text/html');
|
||||
return editor.dom.isEmpty(fragment);
|
||||
}
|
||||
}
|
||||
};
|
||||
const hasDraft = (editor) => {
|
||||
const time = parseInt(global$2.getItem(getAutoSavePrefix(editor) + 'time') ?? '0', 10) || 0;
|
||||
if (new Date().getTime() - time > getAutoSaveRetention(editor)) {
|
||||
removeDraft(editor, false);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const removeDraft = (editor, fire) => {
|
||||
const prefix = getAutoSavePrefix(editor);
|
||||
global$2.removeItem(prefix + 'draft');
|
||||
global$2.removeItem(prefix + 'time');
|
||||
if (fire !== false) {
|
||||
fireRemoveDraft(editor);
|
||||
}
|
||||
};
|
||||
const storeDraft = (editor) => {
|
||||
const prefix = getAutoSavePrefix(editor);
|
||||
if (!isEmpty(editor) && editor.isDirty()) {
|
||||
global$2.setItem(prefix + 'draft', editor.getContent({ format: 'raw', no_events: true }));
|
||||
global$2.setItem(prefix + 'time', new Date().getTime().toString());
|
||||
fireStoreDraft(editor);
|
||||
}
|
||||
};
|
||||
const restoreDraft = (editor) => {
|
||||
const prefix = getAutoSavePrefix(editor);
|
||||
if (hasDraft(editor)) {
|
||||
editor.setContent(global$2.getItem(prefix + 'draft') ?? '', { format: 'raw' });
|
||||
fireRestoreDraft(editor);
|
||||
}
|
||||
};
|
||||
const startStoreDraft = (editor) => {
|
||||
const interval = getAutoSaveInterval(editor);
|
||||
global$3.setEditorInterval(editor, () => {
|
||||
storeDraft(editor);
|
||||
}, interval);
|
||||
};
|
||||
const restoreLastDraft = (editor) => {
|
||||
editor.undoManager.transact(() => {
|
||||
restoreDraft(editor);
|
||||
removeDraft(editor);
|
||||
});
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
const get = (editor) => ({
|
||||
hasDraft: () => hasDraft(editor),
|
||||
storeDraft: () => storeDraft(editor),
|
||||
restoreDraft: () => restoreDraft(editor),
|
||||
removeDraft: (fire) => removeDraft(editor, fire),
|
||||
isEmpty: (html) => isEmpty(editor, html)
|
||||
});
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.EditorManager');
|
||||
|
||||
const setup = (editor) => {
|
||||
editor.editorManager.on('BeforeUnload', (e) => {
|
||||
let msg;
|
||||
global$1.each(global.get(), (editor) => {
|
||||
// Store a draft for each editor instance
|
||||
if (editor.plugins.autosave) {
|
||||
editor.plugins.autosave.storeDraft();
|
||||
}
|
||||
// Setup a return message if the editor is dirty
|
||||
if (!msg && editor.isDirty() && shouldAskBeforeUnload(editor)) {
|
||||
msg = editor.translate('You have unsaved changes are you sure you want to navigate away?');
|
||||
}
|
||||
});
|
||||
if (msg) {
|
||||
e.preventDefault();
|
||||
e.returnValue = msg;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const makeSetupHandler = (editor) => (api) => {
|
||||
const shouldEnable = () => hasDraft(editor) && !editor.mode.isReadOnly();
|
||||
api.setEnabled(shouldEnable());
|
||||
const editorEventCallback = () => api.setEnabled(shouldEnable());
|
||||
editor.on('StoreDraft RestoreDraft RemoveDraft', editorEventCallback);
|
||||
return () => editor.off('StoreDraft RestoreDraft RemoveDraft', editorEventCallback);
|
||||
};
|
||||
const register = (editor) => {
|
||||
// TODO: This was moved from makeSetupHandler as it would only be called when the menu item was rendered?
|
||||
// Is it safe to start this process when the plugin is registered?
|
||||
startStoreDraft(editor);
|
||||
const onAction = () => {
|
||||
restoreLastDraft(editor);
|
||||
};
|
||||
editor.ui.registry.addButton('restoredraft', {
|
||||
tooltip: 'Restore last draft',
|
||||
icon: 'restore-draft',
|
||||
onAction,
|
||||
onSetup: makeSetupHandler(editor)
|
||||
});
|
||||
editor.ui.registry.addMenuItem('restoredraft', {
|
||||
text: 'Restore last draft',
|
||||
icon: 'restore-draft',
|
||||
onAction,
|
||||
onSetup: makeSetupHandler(editor)
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* This class contains all core logic for the autosave plugin.
|
||||
*
|
||||
* @class tinymce.autosave.Plugin
|
||||
* @private
|
||||
*/
|
||||
var Plugin = () => {
|
||||
global$4.add('autosave', (editor) => {
|
||||
register$1(editor);
|
||||
setup(editor);
|
||||
register(editor);
|
||||
editor.on('init', () => {
|
||||
if (shouldRestoreWhenEmpty(editor) && editor.dom.isEmpty(editor.getBody())) {
|
||||
restoreDraft(editor);
|
||||
}
|
||||
});
|
||||
return get(editor);
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
+1
-4
@@ -1,4 +1 @@
|
||||
/**
|
||||
* TinyMCE version 7.3.0 (2024-08-07)
|
||||
*/
|
||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=("string",t=>"string"===(t=>{const e=typeof t;return null===t?"null":"object"===e&&Array.isArray(t)?"array":"object"===e&&(r=o=t,(a=String).prototype.isPrototypeOf(r)||(null===(s=o.constructor)||void 0===s?void 0:s.name)===a.name)?"string":e;var r,o,a,s})(t));const r=(void 0,t=>undefined===t);var o=tinymce.util.Tools.resolve("tinymce.util.Delay"),a=tinymce.util.Tools.resolve("tinymce.util.LocalStorage"),s=tinymce.util.Tools.resolve("tinymce.util.Tools");const n=t=>{const e=/^(\d+)([ms]?)$/.exec(t);return(e&&e[2]?{s:1e3,m:6e4}[e[2]]:1)*parseInt(t,10)},i=t=>e=>e.options.get(t),u=i("autosave_ask_before_unload"),l=i("autosave_restore_when_empty"),c=i("autosave_interval"),d=i("autosave_retention"),m=t=>{const e=document.location;return t.options.get("autosave_prefix").replace(/{path}/g,e.pathname).replace(/{query}/g,e.search).replace(/{hash}/g,e.hash).replace(/{id}/g,t.id)},v=(t,e)=>{if(r(e))return t.dom.isEmpty(t.getBody());{const r=s.trim(e);if(""===r)return!0;{const e=(new DOMParser).parseFromString(r,"text/html");return t.dom.isEmpty(e)}}},f=t=>{var e;const r=parseInt(null!==(e=a.getItem(m(t)+"time"))&&void 0!==e?e:"0",10)||0;return!((new Date).getTime()-r>d(t)&&(p(t,!1),1))},p=(t,e)=>{const r=m(t);a.removeItem(r+"draft"),a.removeItem(r+"time"),!1!==e&&(t=>{t.dispatch("RemoveDraft")})(t)},g=t=>{const e=m(t);!v(t)&&t.isDirty()&&(a.setItem(e+"draft",t.getContent({format:"raw",no_events:!0})),a.setItem(e+"time",(new Date).getTime().toString()),(t=>{t.dispatch("StoreDraft")})(t))},y=t=>{var e;const r=m(t);f(t)&&(t.setContent(null!==(e=a.getItem(r+"draft"))&&void 0!==e?e:"",{format:"raw"}),(t=>{t.dispatch("RestoreDraft")})(t))};var D=tinymce.util.Tools.resolve("tinymce.EditorManager");const h=t=>e=>{e.setEnabled(f(t));const r=()=>e.setEnabled(f(t));return t.on("StoreDraft RestoreDraft RemoveDraft",r),()=>t.off("StoreDraft RestoreDraft RemoveDraft",r)};t.add("autosave",(t=>((t=>{const r=t.options.register,o=t=>{const r=e(t);return r?{value:n(t),valid:r}:{valid:!1,message:"Must be a string."}};r("autosave_ask_before_unload",{processor:"boolean",default:!0}),r("autosave_prefix",{processor:"string",default:"tinymce-autosave-{path}{query}{hash}-{id}-"}),r("autosave_restore_when_empty",{processor:"boolean",default:!1}),r("autosave_interval",{processor:o,default:"30s"}),r("autosave_retention",{processor:o,default:"20m"})})(t),(t=>{t.editorManager.on("BeforeUnload",(t=>{let e;s.each(D.get(),(t=>{t.plugins.autosave&&t.plugins.autosave.storeDraft(),!e&&t.isDirty()&&u(t)&&(e=t.translate("You have unsaved changes are you sure you want to navigate away?"))})),e&&(t.preventDefault(),t.returnValue=e)}))})(t),(t=>{(t=>{const e=c(t);o.setEditorInterval(t,(()=>{g(t)}),e)})(t);const e=()=>{(t=>{t.undoManager.transact((()=>{y(t),p(t)})),t.focus()})(t)};t.ui.registry.addButton("restoredraft",{tooltip:"Restore last draft",icon:"restore-draft",onAction:e,onSetup:h(t)}),t.ui.registry.addMenuItem("restoredraft",{text:"Restore last draft",icon:"restore-draft",onAction:e,onSetup:h(t)})})(t),t.on("init",(()=>{l(t)&&t.dom.isEmpty(t.getBody())&&y(t)})),(t=>({hasDraft:()=>f(t),storeDraft:()=>g(t),restoreDraft:()=>y(t),removeDraft:e=>p(t,e),isEmpty:e=>v(t,e)}))(t))))}();
|
||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=t=>"string"===(t=>{const e=typeof t;return null===t?"null":"object"===e&&Array.isArray(t)?"array":"object"===e&&(r=o=t,(a=String).prototype.isPrototypeOf(r)||o.constructor?.name===a.name)?"string":e;var r,o,a})(t);const r=t=>undefined===t;var o=tinymce.util.Tools.resolve("tinymce.util.Delay"),a=tinymce.util.Tools.resolve("tinymce.util.LocalStorage"),s=tinymce.util.Tools.resolve("tinymce.util.Tools");const n=t=>{const e=/^(\d+)([ms]?)$/.exec(t);return(e&&e[2]?{s:1e3,m:6e4}[e[2]]:1)*parseInt(t,10)},i=t=>e=>e.options.get(t),u=i("autosave_ask_before_unload"),c=i("autosave_restore_when_empty"),l=i("autosave_interval"),m=i("autosave_retention"),d=t=>{const e=document.location;return t.options.get("autosave_prefix").replace(/{path}/g,e.pathname).replace(/{query}/g,e.search).replace(/{hash}/g,e.hash).replace(/{id}/g,t.id)},f=(t,e)=>{if(r(e))return t.dom.isEmpty(t.getBody());{const r=s.trim(e);if(""===r)return!0;{const e=(new DOMParser).parseFromString(r,"text/html");return t.dom.isEmpty(e)}}},v=t=>{const e=parseInt(a.getItem(d(t)+"time")??"0",10)||0;return!((new Date).getTime()-e>m(t)&&(p(t,!1),1))},p=(t,e)=>{const r=d(t);a.removeItem(r+"draft"),a.removeItem(r+"time"),!1!==e&&(t=>{t.dispatch("RemoveDraft")})(t)},y=t=>{const e=d(t);!f(t)&&t.isDirty()&&(a.setItem(e+"draft",t.getContent({format:"raw",no_events:!0})),a.setItem(e+"time",(new Date).getTime().toString()),(t=>{t.dispatch("StoreDraft")})(t))},g=t=>{const e=d(t);v(t)&&(t.setContent(a.getItem(e+"draft")??"",{format:"raw"}),(t=>{t.dispatch("RestoreDraft")})(t))};var D=tinymce.util.Tools.resolve("tinymce.EditorManager");const h=t=>e=>{const r=()=>v(t)&&!t.mode.isReadOnly();e.setEnabled(r());const o=()=>e.setEnabled(r());return t.on("StoreDraft RestoreDraft RemoveDraft",o),()=>t.off("StoreDraft RestoreDraft RemoveDraft",o)};t.add("autosave",t=>((t=>{const r=t.options.register,o=t=>{const r=e(t);return r?{value:n(t),valid:r}:{valid:!1,message:"Must be a string."}};r("autosave_ask_before_unload",{processor:"boolean",default:!0}),r("autosave_prefix",{processor:"string",default:"tinymce-autosave-{path}{query}{hash}-{id}-"}),r("autosave_restore_when_empty",{processor:"boolean",default:!1}),r("autosave_interval",{processor:o,default:"30s"}),r("autosave_retention",{processor:o,default:"20m"})})(t),(t=>{t.editorManager.on("BeforeUnload",t=>{let e;s.each(D.get(),t=>{t.plugins.autosave&&t.plugins.autosave.storeDraft(),!e&&t.isDirty()&&u(t)&&(e=t.translate("You have unsaved changes are you sure you want to navigate away?"))}),e&&(t.preventDefault(),t.returnValue=e)})})(t),(t=>{(t=>{const e=l(t);o.setEditorInterval(t,()=>{y(t)},e)})(t);const e=()=>{(t=>{t.undoManager.transact(()=>{g(t),p(t)}),t.focus()})(t)};t.ui.registry.addButton("restoredraft",{tooltip:"Restore last draft",icon:"restore-draft",onAction:e,onSetup:h(t)}),t.ui.registry.addMenuItem("restoredraft",{text:"Restore last draft",icon:"restore-draft",onAction:e,onSetup:h(t)})})(t),t.on("init",()=>{c(t)&&t.dom.isEmpty(t.getBody())&&g(t)}),(t=>({hasDraft:()=>v(t),storeDraft:()=>y(t),restoreDraft:()=>g(t),removeDraft:e=>p(t,e),isEmpty:e=>f(t,e)}))(t)))}();
|
||||
@@ -0,0 +1,7 @@
|
||||
// Exports the "charmap" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/charmap')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/charmap'
|
||||
require('./plugin.js');
|
||||
@@ -0,0 +1,997 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
const fireInsertCustomChar = (editor, chr) => {
|
||||
return editor.dispatch('insertCustomChar', { chr });
|
||||
};
|
||||
|
||||
const insertChar = (editor, chr) => {
|
||||
const evtChr = fireInsertCustomChar(editor, chr).chr;
|
||||
editor.execCommand('mceInsertContent', false, evtChr);
|
||||
};
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-wrapper-object-types */
|
||||
const hasProto = (v, constructor, predicate) => {
|
||||
if (predicate(v, constructor.prototype)) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
// String-based fallback time
|
||||
return v.constructor?.name === constructor.name;
|
||||
}
|
||||
};
|
||||
const typeOf = (x) => {
|
||||
const t = typeof x;
|
||||
if (x === null) {
|
||||
return 'null';
|
||||
}
|
||||
else if (t === 'object' && Array.isArray(x)) {
|
||||
return 'array';
|
||||
}
|
||||
else if (t === 'object' && hasProto(x, String, (o, proto) => proto.isPrototypeOf(o))) {
|
||||
return 'string';
|
||||
}
|
||||
else {
|
||||
return t;
|
||||
}
|
||||
};
|
||||
const isType = (type) => (value) => typeOf(value) === type;
|
||||
const isSimpleType = (type) => (value) => typeof value === type;
|
||||
const eq = (t) => (a) => t === a;
|
||||
const isArray$1 = isType('array');
|
||||
const isNull = eq(null);
|
||||
const isUndefined = eq(undefined);
|
||||
const isNullable = (a) => a === null || a === undefined;
|
||||
const isNonNullable = (a) => !isNullable(a);
|
||||
const isFunction = isSimpleType('function');
|
||||
|
||||
const constant = (value) => {
|
||||
return () => {
|
||||
return value;
|
||||
};
|
||||
};
|
||||
const never = constant(false);
|
||||
|
||||
/**
|
||||
* The `Optional` type represents a value (of any type) that potentially does
|
||||
* not exist. Any `Optional<T>` can either be a `Some<T>` (in which case the
|
||||
* value does exist) or a `None` (in which case the value does not exist). This
|
||||
* module defines a whole lot of FP-inspired utility functions for dealing with
|
||||
* `Optional` objects.
|
||||
*
|
||||
* Comparison with null or undefined:
|
||||
* - We don't get fancy null coalescing operators with `Optional`
|
||||
* - We do get fancy helper functions with `Optional`
|
||||
* - `Optional` support nesting, and allow for the type to still be nullable (or
|
||||
* another `Optional`)
|
||||
* - There is no option to turn off strict-optional-checks like there is for
|
||||
* strict-null-checks
|
||||
*/
|
||||
class Optional {
|
||||
tag;
|
||||
value;
|
||||
// Sneaky optimisation: every instance of Optional.none is identical, so just
|
||||
// reuse the same object
|
||||
static singletonNone = new Optional(false);
|
||||
// The internal representation has a `tag` and a `value`, but both are
|
||||
// private: able to be console.logged, but not able to be accessed by code
|
||||
constructor(tag, value) {
|
||||
this.tag = tag;
|
||||
this.value = value;
|
||||
}
|
||||
// --- Identities ---
|
||||
/**
|
||||
* Creates a new `Optional<T>` that **does** contain a value.
|
||||
*/
|
||||
static some(value) {
|
||||
return new Optional(true, value);
|
||||
}
|
||||
/**
|
||||
* Create a new `Optional<T>` that **does not** contain a value. `T` can be
|
||||
* any type because we don't actually have a `T`.
|
||||
*/
|
||||
static none() {
|
||||
return Optional.singletonNone;
|
||||
}
|
||||
/**
|
||||
* Perform a transform on an `Optional` type. Regardless of whether this
|
||||
* `Optional` contains a value or not, `fold` will return a value of type `U`.
|
||||
* If this `Optional` does not contain a value, the `U` will be created by
|
||||
* calling `onNone`. If this `Optional` does contain a value, the `U` will be
|
||||
* created by calling `onSome`.
|
||||
*
|
||||
* For the FP enthusiasts in the room, this function:
|
||||
* 1. Could be used to implement all of the functions below
|
||||
* 2. Forms a catamorphism
|
||||
*/
|
||||
fold(onNone, onSome) {
|
||||
if (this.tag) {
|
||||
return onSome(this.value);
|
||||
}
|
||||
else {
|
||||
return onNone();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Determine if this `Optional` object contains a value.
|
||||
*/
|
||||
isSome() {
|
||||
return this.tag;
|
||||
}
|
||||
/**
|
||||
* Determine if this `Optional` object **does not** contain a value.
|
||||
*/
|
||||
isNone() {
|
||||
return !this.tag;
|
||||
}
|
||||
// --- Functor (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* Perform a transform on an `Optional` object, **if** there is a value. If
|
||||
* you provide a function to turn a T into a U, this is the function you use
|
||||
* to turn an `Optional<T>` into an `Optional<U>`. If this **does** contain
|
||||
* a value then the output will also contain a value (that value being the
|
||||
* output of `mapper(this.value)`), and if this **does not** contain a value
|
||||
* then neither will the output.
|
||||
*/
|
||||
map(mapper) {
|
||||
if (this.tag) {
|
||||
return Optional.some(mapper(this.value));
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Monad (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* Perform a transform on an `Optional` object, **if** there is a value.
|
||||
* Unlike `map`, here the transform itself also returns an `Optional`.
|
||||
*/
|
||||
bind(binder) {
|
||||
if (this.tag) {
|
||||
return binder(this.value);
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Traversable (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* For a given predicate, this function finds out if there **exists** a value
|
||||
* inside this `Optional` object that meets the predicate. In practice, this
|
||||
* means that for `Optional`s that do not contain a value it returns false (as
|
||||
* no predicate-meeting value exists).
|
||||
*/
|
||||
exists(predicate) {
|
||||
return this.tag && predicate(this.value);
|
||||
}
|
||||
/**
|
||||
* For a given predicate, this function finds out if **all** the values inside
|
||||
* this `Optional` object meet the predicate. In practice, this means that
|
||||
* for `Optional`s that do not contain a value it returns true (as all 0
|
||||
* objects do meet the predicate).
|
||||
*/
|
||||
forall(predicate) {
|
||||
return !this.tag || predicate(this.value);
|
||||
}
|
||||
filter(predicate) {
|
||||
if (!this.tag || predicate(this.value)) {
|
||||
return this;
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Getters ---
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value.
|
||||
*/
|
||||
getOr(replacement) {
|
||||
return this.tag ? this.value : replacement;
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value. Unlike `getOr`, in this method the `replacement` object is also
|
||||
* `Optional` - meaning that this method will always return an `Optional`.
|
||||
*/
|
||||
or(replacement) {
|
||||
return this.tag ? this : replacement;
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value. Unlike `getOr`, in this method the `replacement` value is
|
||||
* "thunked" - that is to say that you don't pass a value to `getOrThunk`, you
|
||||
* pass a function which (if called) will **return** the `value` you want to
|
||||
* use.
|
||||
*/
|
||||
getOrThunk(thunk) {
|
||||
return this.tag ? this.value : thunk();
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided Optional object does not contain a
|
||||
* value.
|
||||
*
|
||||
* Unlike `or`, in this method the `replacement` value is "thunked" - that is
|
||||
* to say that you don't pass a value to `orThunk`, you pass a function which
|
||||
* (if called) will **return** the `value` you want to use.
|
||||
*
|
||||
* Unlike `getOrThunk`, in this method the `replacement` value is also
|
||||
* `Optional`, meaning that this method will always return an `Optional`.
|
||||
*/
|
||||
orThunk(thunk) {
|
||||
return this.tag ? this : thunk();
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, throwing an
|
||||
* exception if the provided `Optional` object does not contain a value.
|
||||
*
|
||||
* WARNING:
|
||||
* You should only be using this function if you know that the `Optional`
|
||||
* object **is not** empty (otherwise you're throwing exceptions in production
|
||||
* code, which is bad).
|
||||
*
|
||||
* In tests this is more acceptable.
|
||||
*
|
||||
* Prefer other methods to this, such as `.each`.
|
||||
*/
|
||||
getOrDie(message) {
|
||||
if (!this.tag) {
|
||||
throw new Error(message ?? 'Called getOrDie on None');
|
||||
}
|
||||
else {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
// --- Interop with null and undefined ---
|
||||
/**
|
||||
* Creates an `Optional` value from a nullable (or undefined-able) input.
|
||||
* Null, or undefined, is converted to `None`, and anything else is converted
|
||||
* to `Some`.
|
||||
*/
|
||||
static from(value) {
|
||||
return isNonNullable(value) ? Optional.some(value) : Optional.none();
|
||||
}
|
||||
/**
|
||||
* Converts an `Optional` to a nullable type, by getting the value if it
|
||||
* exists, or returning `null` if it does not.
|
||||
*/
|
||||
getOrNull() {
|
||||
return this.tag ? this.value : null;
|
||||
}
|
||||
/**
|
||||
* Converts an `Optional` to an undefined-able type, by getting the value if
|
||||
* it exists, or returning `undefined` if it does not.
|
||||
*/
|
||||
getOrUndefined() {
|
||||
return this.value;
|
||||
}
|
||||
// --- Utilities ---
|
||||
/**
|
||||
* If the `Optional` contains a value, perform an action on that value.
|
||||
* Unlike the rest of the methods on this type, `.each` has side-effects. If
|
||||
* you want to transform an `Optional<T>` **into** something, then this is not
|
||||
* the method for you. If you want to use an `Optional<T>` to **do**
|
||||
* something, then this is the method for you - provided you're okay with not
|
||||
* doing anything in the case where the `Optional` doesn't have a value inside
|
||||
* it. If you're not sure whether your use-case fits into transforming
|
||||
* **into** something or **doing** something, check whether it has a return
|
||||
* value. If it does, you should be performing a transform.
|
||||
*/
|
||||
each(worker) {
|
||||
if (this.tag) {
|
||||
worker(this.value);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Turn the `Optional` object into an array that contains all of the values
|
||||
* stored inside the `Optional`. In practice, this means the output will have
|
||||
* either 0 or 1 elements.
|
||||
*/
|
||||
toArray() {
|
||||
return this.tag ? [this.value] : [];
|
||||
}
|
||||
/**
|
||||
* Turn the `Optional` object into a string for debugging or printing. Not
|
||||
* recommended for production code, but good for debugging. Also note that
|
||||
* these days an `Optional` object can be logged to the console directly, and
|
||||
* its inner value (if it exists) will be visible.
|
||||
*/
|
||||
toString() {
|
||||
return this.tag ? `some(${this.value})` : 'none()';
|
||||
}
|
||||
}
|
||||
|
||||
const nativeSlice = Array.prototype.slice;
|
||||
const nativePush = Array.prototype.push;
|
||||
const map = (xs, f) => {
|
||||
// pre-allocating array size when it's guaranteed to be known
|
||||
// http://jsperf.com/push-allocated-vs-dynamic/22
|
||||
const len = xs.length;
|
||||
const r = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = xs[i];
|
||||
r[i] = f(x, i);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
// Unwound implementing other functions in terms of each.
|
||||
// The code size is roughly the same, and it should allow for better optimisation.
|
||||
// const each = function<T, U>(xs: T[], f: (x: T, i?: number, xs?: T[]) => void): void {
|
||||
const each = (xs, f) => {
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
f(x, i);
|
||||
}
|
||||
};
|
||||
const findUntil = (xs, pred, until) => {
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
if (pred(x, i)) {
|
||||
return Optional.some(x);
|
||||
}
|
||||
else if (until(x, i)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Optional.none();
|
||||
};
|
||||
const find = (xs, pred) => {
|
||||
return findUntil(xs, pred, never);
|
||||
};
|
||||
const flatten = (xs) => {
|
||||
// Note, this is possible because push supports multiple arguments:
|
||||
// http://jsperf.com/concat-push/6
|
||||
// Note that in the past, concat() would silently work (very slowly) for array-like objects.
|
||||
// With this change it will throw an error.
|
||||
const r = [];
|
||||
for (let i = 0, len = xs.length; i < len; ++i) {
|
||||
// Ensure that each value is an array itself
|
||||
if (!isArray$1(xs[i])) {
|
||||
throw new Error('Arr.flatten item ' + i + ' was not an array, input: ' + xs);
|
||||
}
|
||||
nativePush.apply(r, xs[i]);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
const bind = (xs, f) => flatten(map(xs, f));
|
||||
isFunction(Array.from) ? Array.from : (x) => nativeSlice.call(x);
|
||||
|
||||
const Cell = (initial) => {
|
||||
let value = initial;
|
||||
const get = () => {
|
||||
return value;
|
||||
};
|
||||
const set = (v) => {
|
||||
value = v;
|
||||
};
|
||||
return {
|
||||
get,
|
||||
set
|
||||
};
|
||||
};
|
||||
|
||||
const contains = (str, substr, start = 0, end) => {
|
||||
const idx = str.indexOf(substr, start);
|
||||
if (idx !== -1) {
|
||||
return isUndefined(end) ? true : idx + substr.length <= end;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const fromCodePoint = String.fromCodePoint;
|
||||
|
||||
// Run a function fn after rate ms. If another invocation occurs
|
||||
// during the time it is waiting, reschedule the function again
|
||||
// with the new arguments.
|
||||
const last = (fn, rate) => {
|
||||
let timer = null;
|
||||
const cancel = () => {
|
||||
if (!isNull(timer)) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
const throttle = (...args) => {
|
||||
cancel();
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
fn.apply(null, args);
|
||||
}, rate);
|
||||
};
|
||||
return {
|
||||
cancel,
|
||||
throttle
|
||||
};
|
||||
};
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.util.Tools');
|
||||
|
||||
const option = (name) => (editor) => editor.options.get(name);
|
||||
const register$2 = (editor) => {
|
||||
const registerOption = editor.options.register;
|
||||
const charMapProcessor = (value) => isFunction(value) || isArray$1(value);
|
||||
registerOption('charmap', {
|
||||
processor: charMapProcessor,
|
||||
});
|
||||
registerOption('charmap_append', {
|
||||
processor: charMapProcessor
|
||||
});
|
||||
};
|
||||
const getCharMap$1 = option('charmap');
|
||||
const getCharMapAppend = option('charmap_append');
|
||||
|
||||
const isArray = global.isArray;
|
||||
const UserDefined = 'User Defined';
|
||||
const getDefaultCharMap = () => {
|
||||
return [
|
||||
// TODO: Merge categories with TBIO
|
||||
// {
|
||||
// name: 'Unknown',
|
||||
// characters : [
|
||||
// [160, 'no-break space'],
|
||||
// [173, 'soft hyphen'],
|
||||
// [34, 'quotation mark']
|
||||
// ]
|
||||
// },
|
||||
{ name: 'Currency',
|
||||
characters: [
|
||||
[36, 'dollar sign'],
|
||||
[162, 'cent sign'],
|
||||
[8364, 'euro sign'],
|
||||
[163, 'pound sign'],
|
||||
[165, 'yen sign'],
|
||||
[164, 'currency sign'],
|
||||
[8352, 'euro-currency sign'],
|
||||
[8353, 'colon sign'],
|
||||
[8354, 'cruzeiro sign'],
|
||||
[8355, 'french franc sign'],
|
||||
[8356, 'lira sign'],
|
||||
[8357, 'mill sign'],
|
||||
[8358, 'naira sign'],
|
||||
[8359, 'peseta sign'],
|
||||
[8360, 'rupee sign'],
|
||||
[8361, 'won sign'],
|
||||
[8362, 'new sheqel sign'],
|
||||
[8363, 'dong sign'],
|
||||
[8365, 'kip sign'],
|
||||
[8366, 'tugrik sign'],
|
||||
[8367, 'drachma sign'],
|
||||
[8368, 'german penny symbol'],
|
||||
[8369, 'peso sign'],
|
||||
[8370, 'guarani sign'],
|
||||
[8371, 'austral sign'],
|
||||
[8372, 'hryvnia sign'],
|
||||
[8373, 'cedi sign'],
|
||||
[8374, 'livre tournois sign'],
|
||||
[8375, 'spesmilo sign'],
|
||||
[8376, 'tenge sign'],
|
||||
[8377, 'indian rupee sign'],
|
||||
[8378, 'turkish lira sign'],
|
||||
[8379, 'nordic mark sign'],
|
||||
[8380, 'manat sign'],
|
||||
[8381, 'ruble sign'],
|
||||
[20870, 'yen character'],
|
||||
[20803, 'yuan character'],
|
||||
[22291, 'yuan character, in hong kong and taiwan'],
|
||||
[22278, 'yen/yuan character variant one']
|
||||
]
|
||||
},
|
||||
{ name: 'Text',
|
||||
characters: [
|
||||
[169, 'copyright sign'],
|
||||
[174, 'registered sign'],
|
||||
[8482, 'trade mark sign'],
|
||||
[8240, 'per mille sign'],
|
||||
[181, 'micro sign'],
|
||||
[183, 'middle dot'],
|
||||
[8226, 'bullet'],
|
||||
[8230, 'three dot leader'],
|
||||
[8242, 'minutes / feet'],
|
||||
[8243, 'seconds / inches'],
|
||||
[167, 'section sign'],
|
||||
[182, 'paragraph sign'],
|
||||
[223, 'sharp s / ess-zed']
|
||||
]
|
||||
},
|
||||
{ name: 'Quotations',
|
||||
characters: [
|
||||
[8249, 'single left-pointing angle quotation mark'],
|
||||
[8250, 'single right-pointing angle quotation mark'],
|
||||
[171, 'left pointing guillemet'],
|
||||
[187, 'right pointing guillemet'],
|
||||
[8216, 'left single quotation mark'],
|
||||
[8217, 'right single quotation mark'],
|
||||
[8220, 'left double quotation mark'],
|
||||
[8221, 'right double quotation mark'],
|
||||
[8218, 'single low-9 quotation mark'],
|
||||
[8222, 'double low-9 quotation mark'],
|
||||
[60, 'less-than sign'],
|
||||
[62, 'greater-than sign'],
|
||||
[8804, 'less-than or equal to'],
|
||||
[8805, 'greater-than or equal to'],
|
||||
[8211, 'en dash'],
|
||||
[8212, 'em dash'],
|
||||
[175, 'macron'],
|
||||
[8254, 'overline'],
|
||||
[164, 'currency sign'],
|
||||
[166, 'broken bar'],
|
||||
[168, 'diaeresis'],
|
||||
[161, 'inverted exclamation mark'],
|
||||
[191, 'turned question mark'],
|
||||
[710, 'circumflex accent'],
|
||||
[732, 'small tilde'],
|
||||
[176, 'degree sign'],
|
||||
[8722, 'minus sign'],
|
||||
[177, 'plus-minus sign'],
|
||||
[247, 'division sign'],
|
||||
[8260, 'fraction slash'],
|
||||
[215, 'multiplication sign'],
|
||||
[185, 'superscript one'],
|
||||
[178, 'superscript two'],
|
||||
[179, 'superscript three'],
|
||||
[188, 'fraction one quarter'],
|
||||
[189, 'fraction one half'],
|
||||
[190, 'fraction three quarters']
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Mathematical',
|
||||
characters: [
|
||||
[402, 'function / florin'],
|
||||
[8747, 'integral'],
|
||||
[8721, 'n-ary sumation'],
|
||||
[8734, 'infinity'],
|
||||
[8730, 'square root'],
|
||||
[8764, 'similar to'],
|
||||
[8773, 'approximately equal to'],
|
||||
[8776, 'almost equal to'],
|
||||
[8800, 'not equal to'],
|
||||
[8801, 'identical to'],
|
||||
[8712, 'element of'],
|
||||
[8713, 'not an element of'],
|
||||
[8715, 'contains as member'],
|
||||
[8719, 'n-ary product'],
|
||||
[8743, 'logical and'],
|
||||
[8744, 'logical or'],
|
||||
[172, 'not sign'],
|
||||
[8745, 'intersection'],
|
||||
[8746, 'union'],
|
||||
[8706, 'partial differential'],
|
||||
[8704, 'for all'],
|
||||
[8707, 'there exists'],
|
||||
[8709, 'diameter'],
|
||||
[8711, 'backward difference'],
|
||||
[8727, 'asterisk operator'],
|
||||
[8733, 'proportional to'],
|
||||
[8736, 'angle']
|
||||
]
|
||||
},
|
||||
// TODO: Merge categories with TBIO
|
||||
// {
|
||||
// name: 'Undefined',
|
||||
// characters: [
|
||||
// [180, 'acute accent'],
|
||||
// [184, 'cedilla'],
|
||||
// [170, 'feminine ordinal indicator'],
|
||||
// [186, 'masculine ordinal indicator'],
|
||||
// [8224, 'dagger'],
|
||||
// [8225, 'double dagger']
|
||||
// ]
|
||||
// },
|
||||
{
|
||||
name: 'Extended Latin',
|
||||
characters: [
|
||||
[192, 'A - grave'],
|
||||
[193, 'A - acute'],
|
||||
[194, 'A - circumflex'],
|
||||
[195, 'A - tilde'],
|
||||
[196, 'A - diaeresis'],
|
||||
[197, 'A - ring above'],
|
||||
[256, 'A - macron'],
|
||||
[198, 'ligature AE'],
|
||||
[199, 'C - cedilla'],
|
||||
[200, 'E - grave'],
|
||||
[201, 'E - acute'],
|
||||
[202, 'E - circumflex'],
|
||||
[203, 'E - diaeresis'],
|
||||
[274, 'E - macron'],
|
||||
[204, 'I - grave'],
|
||||
[205, 'I - acute'],
|
||||
[206, 'I - circumflex'],
|
||||
[207, 'I - diaeresis'],
|
||||
[298, 'I - macron'],
|
||||
[208, 'ETH'],
|
||||
[209, 'N - tilde'],
|
||||
[210, 'O - grave'],
|
||||
[211, 'O - acute'],
|
||||
[212, 'O - circumflex'],
|
||||
[213, 'O - tilde'],
|
||||
[214, 'O - diaeresis'],
|
||||
[216, 'O - slash'],
|
||||
[332, 'O - macron'],
|
||||
[338, 'ligature OE'],
|
||||
[352, 'S - caron'],
|
||||
[217, 'U - grave'],
|
||||
[218, 'U - acute'],
|
||||
[219, 'U - circumflex'],
|
||||
[220, 'U - diaeresis'],
|
||||
[362, 'U - macron'],
|
||||
[221, 'Y - acute'],
|
||||
[376, 'Y - diaeresis'],
|
||||
[562, 'Y - macron'],
|
||||
[222, 'THORN'],
|
||||
[224, 'a - grave'],
|
||||
[225, 'a - acute'],
|
||||
[226, 'a - circumflex'],
|
||||
[227, 'a - tilde'],
|
||||
[228, 'a - diaeresis'],
|
||||
[229, 'a - ring above'],
|
||||
[257, 'a - macron'],
|
||||
[230, 'ligature ae'],
|
||||
[231, 'c - cedilla'],
|
||||
[232, 'e - grave'],
|
||||
[233, 'e - acute'],
|
||||
[234, 'e - circumflex'],
|
||||
[235, 'e - diaeresis'],
|
||||
[275, 'e - macron'],
|
||||
[236, 'i - grave'],
|
||||
[237, 'i - acute'],
|
||||
[238, 'i - circumflex'],
|
||||
[239, 'i - diaeresis'],
|
||||
[299, 'i - macron'],
|
||||
[240, 'eth'],
|
||||
[241, 'n - tilde'],
|
||||
[242, 'o - grave'],
|
||||
[243, 'o - acute'],
|
||||
[244, 'o - circumflex'],
|
||||
[245, 'o - tilde'],
|
||||
[246, 'o - diaeresis'],
|
||||
[248, 'o slash'],
|
||||
[333, 'o macron'],
|
||||
[339, 'ligature oe'],
|
||||
[353, 's - caron'],
|
||||
[249, 'u - grave'],
|
||||
[250, 'u - acute'],
|
||||
[251, 'u - circumflex'],
|
||||
[252, 'u - diaeresis'],
|
||||
[363, 'u - macron'],
|
||||
[253, 'y - acute'],
|
||||
[254, 'thorn'],
|
||||
[255, 'y - diaeresis'],
|
||||
[563, 'y - macron'],
|
||||
[913, 'Alpha'],
|
||||
[914, 'Beta'],
|
||||
[915, 'Gamma'],
|
||||
[916, 'Delta'],
|
||||
[917, 'Epsilon'],
|
||||
[918, 'Zeta'],
|
||||
[919, 'Eta'],
|
||||
[920, 'Theta'],
|
||||
[921, 'Iota'],
|
||||
[922, 'Kappa'],
|
||||
[923, 'Lambda'],
|
||||
[924, 'Mu'],
|
||||
[925, 'Nu'],
|
||||
[926, 'Xi'],
|
||||
[927, 'Omicron'],
|
||||
[928, 'Pi'],
|
||||
[929, 'Rho'],
|
||||
[931, 'Sigma'],
|
||||
[932, 'Tau'],
|
||||
[933, 'Upsilon'],
|
||||
[934, 'Phi'],
|
||||
[935, 'Chi'],
|
||||
[936, 'Psi'],
|
||||
[937, 'Omega'],
|
||||
[945, 'alpha'],
|
||||
[946, 'beta'],
|
||||
[947, 'gamma'],
|
||||
[948, 'delta'],
|
||||
[949, 'epsilon'],
|
||||
[950, 'zeta'],
|
||||
[951, 'eta'],
|
||||
[952, 'theta'],
|
||||
[953, 'iota'],
|
||||
[954, 'kappa'],
|
||||
[955, 'lambda'],
|
||||
[956, 'mu'],
|
||||
[957, 'nu'],
|
||||
[958, 'xi'],
|
||||
[959, 'omicron'],
|
||||
[960, 'pi'],
|
||||
[961, 'rho'],
|
||||
[962, 'final sigma'],
|
||||
[963, 'sigma'],
|
||||
[964, 'tau'],
|
||||
[965, 'upsilon'],
|
||||
[966, 'phi'],
|
||||
[967, 'chi'],
|
||||
[968, 'psi'],
|
||||
[969, 'omega']
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Symbols',
|
||||
characters: [
|
||||
[8501, 'alef symbol'],
|
||||
[982, 'pi symbol'],
|
||||
[8476, 'real part symbol'],
|
||||
[978, 'upsilon - hook symbol'],
|
||||
[8472, 'Weierstrass p'],
|
||||
[8465, 'imaginary part']
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Arrows',
|
||||
characters: [
|
||||
[8592, 'leftwards arrow'],
|
||||
[8593, 'upwards arrow'],
|
||||
[8594, 'rightwards arrow'],
|
||||
[8595, 'downwards arrow'],
|
||||
[8596, 'left right arrow'],
|
||||
[8629, 'carriage return'],
|
||||
[8656, 'leftwards double arrow'],
|
||||
[8657, 'upwards double arrow'],
|
||||
[8658, 'rightwards double arrow'],
|
||||
[8659, 'downwards double arrow'],
|
||||
[8660, 'left right double arrow'],
|
||||
[8756, 'therefore'],
|
||||
[8834, 'subset of'],
|
||||
[8835, 'superset of'],
|
||||
[8836, 'not a subset of'],
|
||||
[8838, 'subset of or equal to'],
|
||||
[8839, 'superset of or equal to'],
|
||||
[8853, 'circled plus'],
|
||||
[8855, 'circled times'],
|
||||
[8869, 'perpendicular'],
|
||||
[8901, 'dot operator'],
|
||||
[8968, 'left ceiling'],
|
||||
[8969, 'right ceiling'],
|
||||
[8970, 'left floor'],
|
||||
[8971, 'right floor'],
|
||||
[9001, 'left-pointing angle bracket'],
|
||||
[9002, 'right-pointing angle bracket'],
|
||||
[9674, 'lozenge'],
|
||||
[9824, 'black spade suit'],
|
||||
[9827, 'black club suit'],
|
||||
[9829, 'black heart suit'],
|
||||
[9830, 'black diamond suit'],
|
||||
[8194, 'en space'],
|
||||
[8195, 'em space'],
|
||||
[8201, 'thin space'],
|
||||
[8204, 'zero width non-joiner'],
|
||||
[8205, 'zero width joiner'],
|
||||
[8206, 'left-to-right mark'],
|
||||
[8207, 'right-to-left mark']
|
||||
]
|
||||
}
|
||||
];
|
||||
};
|
||||
const charmapFilter = (charmap) => {
|
||||
return global.grep(charmap, (item) => {
|
||||
return isArray(item) && item.length === 2;
|
||||
});
|
||||
};
|
||||
const getCharsFromOption = (optionValue) => {
|
||||
if (isArray(optionValue)) {
|
||||
return charmapFilter(optionValue);
|
||||
}
|
||||
if (typeof optionValue === 'function') {
|
||||
return optionValue();
|
||||
}
|
||||
return [];
|
||||
};
|
||||
const extendCharMap = (editor, charmap) => {
|
||||
const userCharMap = getCharMap$1(editor);
|
||||
if (userCharMap) {
|
||||
charmap = [{ name: UserDefined, characters: getCharsFromOption(userCharMap) }];
|
||||
}
|
||||
const userCharMapAppend = getCharMapAppend(editor);
|
||||
if (userCharMapAppend) {
|
||||
const userDefinedGroup = global.grep(charmap, (cg) => cg.name === UserDefined);
|
||||
if (userDefinedGroup.length) {
|
||||
userDefinedGroup[0].characters = [...userDefinedGroup[0].characters, ...getCharsFromOption(userCharMapAppend)];
|
||||
return charmap;
|
||||
}
|
||||
return charmap.concat({ name: UserDefined, characters: getCharsFromOption(userCharMapAppend) });
|
||||
}
|
||||
return charmap;
|
||||
};
|
||||
const getCharMap = (editor) => {
|
||||
const groups = extendCharMap(editor, getDefaultCharMap());
|
||||
return groups.length > 1 ? [
|
||||
{
|
||||
name: 'All',
|
||||
characters: bind(groups, (g) => g.characters)
|
||||
}
|
||||
].concat(groups) : groups;
|
||||
};
|
||||
|
||||
const get = (editor) => {
|
||||
const getCharMap$1 = () => {
|
||||
return getCharMap(editor);
|
||||
};
|
||||
const insertChar$1 = (chr) => {
|
||||
insertChar(editor, chr);
|
||||
};
|
||||
return {
|
||||
getCharMap: getCharMap$1,
|
||||
insertChar: insertChar$1
|
||||
};
|
||||
};
|
||||
|
||||
const charMatches = (charCode, name, lowerCasePattern) => {
|
||||
if (contains(fromCodePoint(charCode).toLowerCase(), lowerCasePattern)) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
return contains(name.toLowerCase(), lowerCasePattern) || contains(name.toLowerCase().replace(/\s+/g, ''), lowerCasePattern);
|
||||
}
|
||||
};
|
||||
const scan = (group, pattern) => {
|
||||
const matches = [];
|
||||
const lowerCasePattern = pattern.toLowerCase();
|
||||
each(group.characters, (g) => {
|
||||
if (charMatches(g[0], g[1], lowerCasePattern)) {
|
||||
matches.push(g);
|
||||
}
|
||||
});
|
||||
return map(matches, (m) => ({
|
||||
text: m[1],
|
||||
value: fromCodePoint(m[0]),
|
||||
icon: fromCodePoint(m[0])
|
||||
}));
|
||||
};
|
||||
|
||||
const patternName = 'pattern';
|
||||
const open = (editor, charMap) => {
|
||||
const makeGroupItems = () => [
|
||||
{
|
||||
label: 'Search',
|
||||
type: 'input',
|
||||
name: patternName
|
||||
},
|
||||
{
|
||||
type: 'collection',
|
||||
name: 'results'
|
||||
// TODO TINY-3229 implement collection columns properly
|
||||
// columns: 'auto'
|
||||
}
|
||||
];
|
||||
const makeTabs = () => map(charMap, (charGroup) => ({
|
||||
title: charGroup.name,
|
||||
name: charGroup.name,
|
||||
items: makeGroupItems()
|
||||
}));
|
||||
const makePanel = () => ({ type: 'panel', items: makeGroupItems() });
|
||||
const makeTabPanel = () => ({ type: 'tabpanel', tabs: makeTabs() });
|
||||
const currentTab = charMap.length === 1 ? Cell(UserDefined) : Cell('All');
|
||||
const scanAndSet = (dialogApi, pattern) => {
|
||||
find(charMap, (group) => group.name === currentTab.get()).each((f) => {
|
||||
const items = scan(f, pattern);
|
||||
dialogApi.setData({
|
||||
results: items
|
||||
});
|
||||
});
|
||||
};
|
||||
const SEARCH_DELAY = 40;
|
||||
const updateFilter = last((dialogApi) => {
|
||||
const pattern = dialogApi.getData().pattern;
|
||||
scanAndSet(dialogApi, pattern);
|
||||
}, SEARCH_DELAY);
|
||||
const body = charMap.length === 1 ? makePanel() : makeTabPanel();
|
||||
const initialData = {
|
||||
pattern: '',
|
||||
results: scan(charMap[0], '')
|
||||
};
|
||||
const bridgeSpec = {
|
||||
title: 'Special Character',
|
||||
size: 'normal',
|
||||
body,
|
||||
buttons: [
|
||||
{
|
||||
type: 'cancel',
|
||||
name: 'close',
|
||||
text: 'Close',
|
||||
primary: true
|
||||
}
|
||||
],
|
||||
initialData,
|
||||
onAction: (api, details) => {
|
||||
if (details.name === 'results') {
|
||||
insertChar(editor, details.value);
|
||||
api.close();
|
||||
}
|
||||
},
|
||||
onTabChange: (dialogApi, details) => {
|
||||
currentTab.set(details.newTabName);
|
||||
updateFilter.throttle(dialogApi);
|
||||
},
|
||||
onChange: (dialogApi, changeData) => {
|
||||
if (changeData.name === patternName) {
|
||||
updateFilter.throttle(dialogApi);
|
||||
}
|
||||
}
|
||||
};
|
||||
const dialogApi = editor.windowManager.open(bridgeSpec);
|
||||
dialogApi.focus(patternName);
|
||||
};
|
||||
|
||||
const register$1 = (editor, charMap) => {
|
||||
editor.addCommand('mceShowCharmap', () => {
|
||||
open(editor, charMap);
|
||||
});
|
||||
};
|
||||
|
||||
const init = (editor, all) => {
|
||||
editor.ui.registry.addAutocompleter('charmap', {
|
||||
trigger: ':',
|
||||
columns: 'auto',
|
||||
minChars: 2,
|
||||
fetch: (pattern, _maxResults) => new Promise((resolve, _reject) => {
|
||||
resolve(scan(all, pattern));
|
||||
}),
|
||||
onAction: (autocompleteApi, rng, value) => {
|
||||
editor.selection.setRng(rng);
|
||||
editor.insertContent(value);
|
||||
autocompleteApi.hide();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSetupEditable = (editor) => (api) => {
|
||||
const nodeChanged = () => {
|
||||
api.setEnabled(editor.selection.isEditable());
|
||||
};
|
||||
editor.on('NodeChange', nodeChanged);
|
||||
nodeChanged();
|
||||
return () => {
|
||||
editor.off('NodeChange', nodeChanged);
|
||||
};
|
||||
};
|
||||
const register = (editor) => {
|
||||
const onAction = () => editor.execCommand('mceShowCharmap');
|
||||
editor.ui.registry.addButton('charmap', {
|
||||
icon: 'insert-character',
|
||||
tooltip: 'Special character',
|
||||
onAction,
|
||||
onSetup: onSetupEditable(editor)
|
||||
});
|
||||
editor.ui.registry.addMenuItem('charmap', {
|
||||
icon: 'insert-character',
|
||||
text: 'Special character...',
|
||||
onAction,
|
||||
onSetup: onSetupEditable(editor)
|
||||
});
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global$1.add('charmap', (editor) => {
|
||||
register$2(editor);
|
||||
const charMap = getCharMap(editor);
|
||||
register$1(editor, charMap);
|
||||
register(editor);
|
||||
init(editor, charMap[0]);
|
||||
return get(editor);
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
+1
-4
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
// Exports the "code" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/code')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/code'
|
||||
require('./plugin.js');
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
const setContent = (editor, html) => {
|
||||
// We get a lovely "Wrong document" error in IE 11 if we
|
||||
// don't move the focus to the editor before creating an undo
|
||||
// transaction since it tries to make a bookmark for the current selection
|
||||
editor.focus();
|
||||
editor.undoManager.transact(() => {
|
||||
editor.setContent(html);
|
||||
});
|
||||
editor.selection.setCursorLocation();
|
||||
editor.nodeChanged();
|
||||
};
|
||||
const getContent = (editor) => {
|
||||
return editor.getContent({ source_view: true });
|
||||
};
|
||||
|
||||
const open = (editor) => {
|
||||
const editorContent = getContent(editor);
|
||||
editor.windowManager.open({
|
||||
title: 'Source Code',
|
||||
size: 'large',
|
||||
body: {
|
||||
type: 'panel',
|
||||
items: [
|
||||
{
|
||||
type: 'textarea',
|
||||
name: 'code',
|
||||
spellcheck: false,
|
||||
}
|
||||
]
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
type: 'cancel',
|
||||
name: 'cancel',
|
||||
text: 'Cancel'
|
||||
},
|
||||
{
|
||||
type: 'submit',
|
||||
name: 'save',
|
||||
text: 'Save',
|
||||
primary: true
|
||||
}
|
||||
],
|
||||
initialData: {
|
||||
code: editorContent
|
||||
},
|
||||
onSubmit: (api) => {
|
||||
setContent(editor, api.getData().code);
|
||||
api.close();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const register$1 = (editor) => {
|
||||
editor.addCommand('mceCodeEditor', () => {
|
||||
open(editor);
|
||||
});
|
||||
};
|
||||
|
||||
const register = (editor) => {
|
||||
const onAction = () => editor.execCommand('mceCodeEditor');
|
||||
editor.ui.registry.addButton('code', {
|
||||
icon: 'sourcecode',
|
||||
tooltip: 'Source code',
|
||||
onAction
|
||||
});
|
||||
editor.ui.registry.addMenuItem('code', {
|
||||
icon: 'sourcecode',
|
||||
text: 'Source code',
|
||||
onAction
|
||||
});
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global.add('code', (editor) => {
|
||||
register$1(editor);
|
||||
register(editor);
|
||||
return {};
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
+1
-4
@@ -1,4 +1 @@
|
||||
/**
|
||||
* TinyMCE version 7.3.0 (2024-08-07)
|
||||
*/
|
||||
!function(){"use strict";tinymce.util.Tools.resolve("tinymce.PluginManager").add("code",(e=>((e=>{e.addCommand("mceCodeEditor",(()=>{(e=>{const o=(e=>e.getContent({source_view:!0}))(e);e.windowManager.open({title:"Source Code",size:"large",body:{type:"panel",items:[{type:"textarea",name:"code"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{code:o},onSubmit:o=>{((e,o)=>{e.focus(),e.undoManager.transact((()=>{e.setContent(o)})),e.selection.setCursorLocation(),e.nodeChanged()})(e,o.getData().code),o.close()}})})(e)}))})(e),(e=>{const o=()=>e.execCommand("mceCodeEditor");e.ui.registry.addButton("code",{icon:"sourcecode",tooltip:"Source code",onAction:o}),e.ui.registry.addMenuItem("code",{icon:"sourcecode",text:"Source code",onAction:o})})(e),{})))}();
|
||||
!function(){"use strict";tinymce.util.Tools.resolve("tinymce.PluginManager").add("code",e=>((e=>{e.addCommand("mceCodeEditor",()=>{(e=>{const o=(e=>e.getContent({source_view:!0}))(e);e.windowManager.open({title:"Source Code",size:"large",body:{type:"panel",items:[{type:"textarea",name:"code",spellcheck:!1}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{code:o},onSubmit:o=>{((e,o)=>{e.focus(),e.undoManager.transact(()=>{e.setContent(o)}),e.selection.setCursorLocation(),e.nodeChanged()})(e,o.getData().code),o.close()}})})(e)})})(e),(e=>{const o=()=>e.execCommand("mceCodeEditor");e.ui.registry.addButton("code",{icon:"sourcecode",tooltip:"Source code",onAction:o}),e.ui.registry.addMenuItem("code",{icon:"sourcecode",text:"Source code",onAction:o})})(e),{}))}();
|
||||
@@ -0,0 +1,7 @@
|
||||
// Exports the "codesample" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/codesample')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/codesample'
|
||||
require('./plugin.js');
|
||||
File diff suppressed because it is too large
Load Diff
+8
-3
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
// Exports the "directionality" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/directionality')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/directionality'
|
||||
require('./plugin.js');
|
||||
@@ -0,0 +1,670 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-wrapper-object-types */
|
||||
const hasProto = (v, constructor, predicate) => {
|
||||
if (predicate(v, constructor.prototype)) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
// String-based fallback time
|
||||
return v.constructor?.name === constructor.name;
|
||||
}
|
||||
};
|
||||
const typeOf = (x) => {
|
||||
const t = typeof x;
|
||||
if (x === null) {
|
||||
return 'null';
|
||||
}
|
||||
else if (t === 'object' && Array.isArray(x)) {
|
||||
return 'array';
|
||||
}
|
||||
else if (t === 'object' && hasProto(x, String, (o, proto) => proto.isPrototypeOf(o))) {
|
||||
return 'string';
|
||||
}
|
||||
else {
|
||||
return t;
|
||||
}
|
||||
};
|
||||
const isType$1 = (type) => (value) => typeOf(value) === type;
|
||||
const isSimpleType = (type) => (value) => typeof value === type;
|
||||
const isString = isType$1('string');
|
||||
const isBoolean = isSimpleType('boolean');
|
||||
const isNullable = (a) => a === null || a === undefined;
|
||||
const isNonNullable = (a) => !isNullable(a);
|
||||
const isFunction = isSimpleType('function');
|
||||
const isNumber = isSimpleType('number');
|
||||
|
||||
/** Compose two unary functions. Similar to compose, but avoids using Function.prototype.apply. */
|
||||
const compose1 = (fbc, fab) => (a) => fbc(fab(a));
|
||||
const constant = (value) => {
|
||||
return () => {
|
||||
return value;
|
||||
};
|
||||
};
|
||||
const tripleEquals = (a, b) => {
|
||||
return a === b;
|
||||
};
|
||||
const never = constant(false);
|
||||
|
||||
/**
|
||||
* The `Optional` type represents a value (of any type) that potentially does
|
||||
* not exist. Any `Optional<T>` can either be a `Some<T>` (in which case the
|
||||
* value does exist) or a `None` (in which case the value does not exist). This
|
||||
* module defines a whole lot of FP-inspired utility functions for dealing with
|
||||
* `Optional` objects.
|
||||
*
|
||||
* Comparison with null or undefined:
|
||||
* - We don't get fancy null coalescing operators with `Optional`
|
||||
* - We do get fancy helper functions with `Optional`
|
||||
* - `Optional` support nesting, and allow for the type to still be nullable (or
|
||||
* another `Optional`)
|
||||
* - There is no option to turn off strict-optional-checks like there is for
|
||||
* strict-null-checks
|
||||
*/
|
||||
class Optional {
|
||||
tag;
|
||||
value;
|
||||
// Sneaky optimisation: every instance of Optional.none is identical, so just
|
||||
// reuse the same object
|
||||
static singletonNone = new Optional(false);
|
||||
// The internal representation has a `tag` and a `value`, but both are
|
||||
// private: able to be console.logged, but not able to be accessed by code
|
||||
constructor(tag, value) {
|
||||
this.tag = tag;
|
||||
this.value = value;
|
||||
}
|
||||
// --- Identities ---
|
||||
/**
|
||||
* Creates a new `Optional<T>` that **does** contain a value.
|
||||
*/
|
||||
static some(value) {
|
||||
return new Optional(true, value);
|
||||
}
|
||||
/**
|
||||
* Create a new `Optional<T>` that **does not** contain a value. `T` can be
|
||||
* any type because we don't actually have a `T`.
|
||||
*/
|
||||
static none() {
|
||||
return Optional.singletonNone;
|
||||
}
|
||||
/**
|
||||
* Perform a transform on an `Optional` type. Regardless of whether this
|
||||
* `Optional` contains a value or not, `fold` will return a value of type `U`.
|
||||
* If this `Optional` does not contain a value, the `U` will be created by
|
||||
* calling `onNone`. If this `Optional` does contain a value, the `U` will be
|
||||
* created by calling `onSome`.
|
||||
*
|
||||
* For the FP enthusiasts in the room, this function:
|
||||
* 1. Could be used to implement all of the functions below
|
||||
* 2. Forms a catamorphism
|
||||
*/
|
||||
fold(onNone, onSome) {
|
||||
if (this.tag) {
|
||||
return onSome(this.value);
|
||||
}
|
||||
else {
|
||||
return onNone();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Determine if this `Optional` object contains a value.
|
||||
*/
|
||||
isSome() {
|
||||
return this.tag;
|
||||
}
|
||||
/**
|
||||
* Determine if this `Optional` object **does not** contain a value.
|
||||
*/
|
||||
isNone() {
|
||||
return !this.tag;
|
||||
}
|
||||
// --- Functor (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* Perform a transform on an `Optional` object, **if** there is a value. If
|
||||
* you provide a function to turn a T into a U, this is the function you use
|
||||
* to turn an `Optional<T>` into an `Optional<U>`. If this **does** contain
|
||||
* a value then the output will also contain a value (that value being the
|
||||
* output of `mapper(this.value)`), and if this **does not** contain a value
|
||||
* then neither will the output.
|
||||
*/
|
||||
map(mapper) {
|
||||
if (this.tag) {
|
||||
return Optional.some(mapper(this.value));
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Monad (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* Perform a transform on an `Optional` object, **if** there is a value.
|
||||
* Unlike `map`, here the transform itself also returns an `Optional`.
|
||||
*/
|
||||
bind(binder) {
|
||||
if (this.tag) {
|
||||
return binder(this.value);
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Traversable (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* For a given predicate, this function finds out if there **exists** a value
|
||||
* inside this `Optional` object that meets the predicate. In practice, this
|
||||
* means that for `Optional`s that do not contain a value it returns false (as
|
||||
* no predicate-meeting value exists).
|
||||
*/
|
||||
exists(predicate) {
|
||||
return this.tag && predicate(this.value);
|
||||
}
|
||||
/**
|
||||
* For a given predicate, this function finds out if **all** the values inside
|
||||
* this `Optional` object meet the predicate. In practice, this means that
|
||||
* for `Optional`s that do not contain a value it returns true (as all 0
|
||||
* objects do meet the predicate).
|
||||
*/
|
||||
forall(predicate) {
|
||||
return !this.tag || predicate(this.value);
|
||||
}
|
||||
filter(predicate) {
|
||||
if (!this.tag || predicate(this.value)) {
|
||||
return this;
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Getters ---
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value.
|
||||
*/
|
||||
getOr(replacement) {
|
||||
return this.tag ? this.value : replacement;
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value. Unlike `getOr`, in this method the `replacement` object is also
|
||||
* `Optional` - meaning that this method will always return an `Optional`.
|
||||
*/
|
||||
or(replacement) {
|
||||
return this.tag ? this : replacement;
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value. Unlike `getOr`, in this method the `replacement` value is
|
||||
* "thunked" - that is to say that you don't pass a value to `getOrThunk`, you
|
||||
* pass a function which (if called) will **return** the `value` you want to
|
||||
* use.
|
||||
*/
|
||||
getOrThunk(thunk) {
|
||||
return this.tag ? this.value : thunk();
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided Optional object does not contain a
|
||||
* value.
|
||||
*
|
||||
* Unlike `or`, in this method the `replacement` value is "thunked" - that is
|
||||
* to say that you don't pass a value to `orThunk`, you pass a function which
|
||||
* (if called) will **return** the `value` you want to use.
|
||||
*
|
||||
* Unlike `getOrThunk`, in this method the `replacement` value is also
|
||||
* `Optional`, meaning that this method will always return an `Optional`.
|
||||
*/
|
||||
orThunk(thunk) {
|
||||
return this.tag ? this : thunk();
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, throwing an
|
||||
* exception if the provided `Optional` object does not contain a value.
|
||||
*
|
||||
* WARNING:
|
||||
* You should only be using this function if you know that the `Optional`
|
||||
* object **is not** empty (otherwise you're throwing exceptions in production
|
||||
* code, which is bad).
|
||||
*
|
||||
* In tests this is more acceptable.
|
||||
*
|
||||
* Prefer other methods to this, such as `.each`.
|
||||
*/
|
||||
getOrDie(message) {
|
||||
if (!this.tag) {
|
||||
throw new Error(message ?? 'Called getOrDie on None');
|
||||
}
|
||||
else {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
// --- Interop with null and undefined ---
|
||||
/**
|
||||
* Creates an `Optional` value from a nullable (or undefined-able) input.
|
||||
* Null, or undefined, is converted to `None`, and anything else is converted
|
||||
* to `Some`.
|
||||
*/
|
||||
static from(value) {
|
||||
return isNonNullable(value) ? Optional.some(value) : Optional.none();
|
||||
}
|
||||
/**
|
||||
* Converts an `Optional` to a nullable type, by getting the value if it
|
||||
* exists, or returning `null` if it does not.
|
||||
*/
|
||||
getOrNull() {
|
||||
return this.tag ? this.value : null;
|
||||
}
|
||||
/**
|
||||
* Converts an `Optional` to an undefined-able type, by getting the value if
|
||||
* it exists, or returning `undefined` if it does not.
|
||||
*/
|
||||
getOrUndefined() {
|
||||
return this.value;
|
||||
}
|
||||
// --- Utilities ---
|
||||
/**
|
||||
* If the `Optional` contains a value, perform an action on that value.
|
||||
* Unlike the rest of the methods on this type, `.each` has side-effects. If
|
||||
* you want to transform an `Optional<T>` **into** something, then this is not
|
||||
* the method for you. If you want to use an `Optional<T>` to **do**
|
||||
* something, then this is the method for you - provided you're okay with not
|
||||
* doing anything in the case where the `Optional` doesn't have a value inside
|
||||
* it. If you're not sure whether your use-case fits into transforming
|
||||
* **into** something or **doing** something, check whether it has a return
|
||||
* value. If it does, you should be performing a transform.
|
||||
*/
|
||||
each(worker) {
|
||||
if (this.tag) {
|
||||
worker(this.value);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Turn the `Optional` object into an array that contains all of the values
|
||||
* stored inside the `Optional`. In practice, this means the output will have
|
||||
* either 0 or 1 elements.
|
||||
*/
|
||||
toArray() {
|
||||
return this.tag ? [this.value] : [];
|
||||
}
|
||||
/**
|
||||
* Turn the `Optional` object into a string for debugging or printing. Not
|
||||
* recommended for production code, but good for debugging. Also note that
|
||||
* these days an `Optional` object can be logged to the console directly, and
|
||||
* its inner value (if it exists) will be visible.
|
||||
*/
|
||||
toString() {
|
||||
return this.tag ? `some(${this.value})` : 'none()';
|
||||
}
|
||||
}
|
||||
|
||||
const nativeSlice = Array.prototype.slice;
|
||||
const map = (xs, f) => {
|
||||
// pre-allocating array size when it's guaranteed to be known
|
||||
// http://jsperf.com/push-allocated-vs-dynamic/22
|
||||
const len = xs.length;
|
||||
const r = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = xs[i];
|
||||
r[i] = f(x, i);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
// Unwound implementing other functions in terms of each.
|
||||
// The code size is roughly the same, and it should allow for better optimisation.
|
||||
// const each = function<T, U>(xs: T[], f: (x: T, i?: number, xs?: T[]) => void): void {
|
||||
const each = (xs, f) => {
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
f(x, i);
|
||||
}
|
||||
};
|
||||
const filter = (xs, pred) => {
|
||||
const r = [];
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
if (pred(x, i)) {
|
||||
r.push(x);
|
||||
}
|
||||
}
|
||||
return r;
|
||||
};
|
||||
isFunction(Array.from) ? Array.from : (x) => nativeSlice.call(x);
|
||||
|
||||
/**
|
||||
* **Is** the value stored inside this Optional object equal to `rhs`?
|
||||
*/
|
||||
const is$1 = (lhs, rhs, comparator = tripleEquals) => lhs.exists((left) => comparator(left, rhs));
|
||||
|
||||
const blank = (r) => (s) => s.replace(r, '');
|
||||
/** removes all leading and trailing spaces */
|
||||
const trim = blank(/^\s+|\s+$/g);
|
||||
const isNotEmpty = (s) => s.length > 0;
|
||||
|
||||
const fromHtml = (html, scope) => {
|
||||
const doc = scope || document;
|
||||
const div = doc.createElement('div');
|
||||
div.innerHTML = html;
|
||||
if (!div.hasChildNodes() || div.childNodes.length > 1) {
|
||||
const message = 'HTML does not have a single root node';
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(message, html);
|
||||
throw new Error(message);
|
||||
}
|
||||
return fromDom(div.childNodes[0]);
|
||||
};
|
||||
const fromTag = (tag, scope) => {
|
||||
const doc = scope || document;
|
||||
const node = doc.createElement(tag);
|
||||
return fromDom(node);
|
||||
};
|
||||
const fromText = (text, scope) => {
|
||||
const doc = scope || document;
|
||||
const node = doc.createTextNode(text);
|
||||
return fromDom(node);
|
||||
};
|
||||
const fromDom = (node) => {
|
||||
// TODO: Consider removing this check, but left atm for safety
|
||||
if (node === null || node === undefined) {
|
||||
throw new Error('Node cannot be null or undefined');
|
||||
}
|
||||
return {
|
||||
dom: node
|
||||
};
|
||||
};
|
||||
const fromPoint = (docElm, x, y) => Optional.from(docElm.dom.elementFromPoint(x, y)).map(fromDom);
|
||||
// tslint:disable-next-line:variable-name
|
||||
const SugarElement = {
|
||||
fromHtml,
|
||||
fromTag,
|
||||
fromText,
|
||||
fromDom,
|
||||
fromPoint
|
||||
};
|
||||
|
||||
const DOCUMENT_FRAGMENT = 11;
|
||||
const ELEMENT = 1;
|
||||
const TEXT = 3;
|
||||
|
||||
const is = (element, selector) => {
|
||||
const dom = element.dom;
|
||||
if (dom.nodeType !== ELEMENT) {
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
const elem = dom;
|
||||
if (elem.matches !== undefined) {
|
||||
return elem.matches(selector);
|
||||
}
|
||||
else if (elem.msMatchesSelector !== undefined) {
|
||||
return elem.msMatchesSelector(selector);
|
||||
}
|
||||
else if (elem.webkitMatchesSelector !== undefined) {
|
||||
return elem.webkitMatchesSelector(selector);
|
||||
}
|
||||
else if (elem.mozMatchesSelector !== undefined) {
|
||||
// cast to any as mozMatchesSelector doesn't exist in TS DOM lib
|
||||
return elem.mozMatchesSelector(selector);
|
||||
}
|
||||
else {
|
||||
throw new Error('Browser lacks native selectors');
|
||||
} // unfortunately we can't throw this on startup :(
|
||||
}
|
||||
};
|
||||
|
||||
const name = (element) => {
|
||||
const r = element.dom.nodeName;
|
||||
return r.toLowerCase();
|
||||
};
|
||||
const type = (element) => element.dom.nodeType;
|
||||
const isType = (t) => (element) => type(element) === t;
|
||||
const isElement = isType(ELEMENT);
|
||||
const isText = isType(TEXT);
|
||||
const isDocumentFragment = isType(DOCUMENT_FRAGMENT);
|
||||
const isTag = (tag) => (e) => isElement(e) && name(e) === tag;
|
||||
|
||||
const children$2 = (element) => map(element.dom.childNodes, SugarElement.fromDom);
|
||||
|
||||
/**
|
||||
* Is the element a ShadowRoot?
|
||||
*
|
||||
* Note: this is insufficient to test if any element is a shadow root, but it is sufficient to differentiate between
|
||||
* a Document and a ShadowRoot.
|
||||
*/
|
||||
const isShadowRoot = (dos) => isDocumentFragment(dos) && isNonNullable(dos.dom.host);
|
||||
const getRootNode = (e) => SugarElement.fromDom(e.dom.getRootNode());
|
||||
/** If this element is in a ShadowRoot, return it. */
|
||||
const getShadowRoot = (e) => {
|
||||
const r = getRootNode(e);
|
||||
return isShadowRoot(r) ? Optional.some(r) : Optional.none();
|
||||
};
|
||||
/** Return the host of a ShadowRoot.
|
||||
*
|
||||
* This function will throw if Shadow DOM is unsupported in the browser, or if the host is null.
|
||||
* If you actually have a ShadowRoot, this shouldn't happen.
|
||||
*/
|
||||
const getShadowHost = (e) => SugarElement.fromDom(e.dom.host);
|
||||
|
||||
const rawSet = (dom, key, value) => {
|
||||
/*
|
||||
* JQuery coerced everything to a string, and silently did nothing on text node/null/undefined.
|
||||
*
|
||||
* We fail on those invalid cases, only allowing numbers and booleans.
|
||||
*/
|
||||
if (isString(value) || isBoolean(value) || isNumber(value)) {
|
||||
dom.setAttribute(key, value + '');
|
||||
}
|
||||
else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Invalid call to Attribute.set. Key ', key, ':: Value ', value, ':: Element ', dom);
|
||||
throw new Error('Attribute value was not simple');
|
||||
}
|
||||
};
|
||||
const set = (element, key, value) => {
|
||||
rawSet(element.dom, key, value);
|
||||
};
|
||||
const get$1 = (element, key) => {
|
||||
const v = element.dom.getAttribute(key);
|
||||
// undefined is the more appropriate value for JS, and this matches JQuery
|
||||
return v === null ? undefined : v;
|
||||
};
|
||||
const getOpt = (element, key) => Optional.from(get$1(element, key));
|
||||
const remove$1 = (element, key) => {
|
||||
element.dom.removeAttribute(key);
|
||||
};
|
||||
|
||||
// some elements, such as mathml, don't have style attributes
|
||||
// others, such as angular elements, have style attributes that aren't a CSSStyleDeclaration
|
||||
const isSupported = (dom) => dom.style !== undefined && isFunction(dom.style.getPropertyValue);
|
||||
|
||||
// Node.contains() is very, very, very good performance
|
||||
// http://jsperf.com/closest-vs-contains/5
|
||||
const inBody = (element) => {
|
||||
// Technically this is only required on IE, where contains() returns false for text nodes.
|
||||
// But it's cheap enough to run everywhere and Sugar doesn't have platform detection (yet).
|
||||
const dom = isText(element) ? element.dom.parentNode : element.dom;
|
||||
// use ownerDocument.body to ensure this works inside iframes.
|
||||
// Normally contains is bad because an element "contains" itself, but here we want that.
|
||||
if (dom === undefined || dom === null || dom.ownerDocument === null) {
|
||||
return false;
|
||||
}
|
||||
const doc = dom.ownerDocument;
|
||||
return getShadowRoot(SugarElement.fromDom(dom)).fold(() => doc.body.contains(dom), compose1(inBody, getShadowHost));
|
||||
};
|
||||
|
||||
const internalRemove = (dom, property) => {
|
||||
/*
|
||||
* IE9 and above - MDN doesn't have details, but here's a couple of random internet claims
|
||||
*
|
||||
* http://help.dottoro.com/ljopsjck.php
|
||||
* http://stackoverflow.com/a/7901886/7546
|
||||
*/
|
||||
if (isSupported(dom)) {
|
||||
dom.style.removeProperty(property);
|
||||
}
|
||||
};
|
||||
/*
|
||||
* NOTE: For certain properties, this returns the "used value" which is subtly different to the "computed value" (despite calling getComputedStyle).
|
||||
* Blame CSS 2.0.
|
||||
*
|
||||
* https://developer.mozilla.org/en-US/docs/Web/CSS/used_value
|
||||
*/
|
||||
const get = (element, property) => {
|
||||
const dom = element.dom;
|
||||
/*
|
||||
* IE9 and above per
|
||||
* https://developer.mozilla.org/en/docs/Web/API/window.getComputedStyle
|
||||
*
|
||||
* Not in numerosity, because it doesn't memoize and looking this up dynamically in performance critical code would be horrendous.
|
||||
*
|
||||
* JQuery has some magic here for IE popups, but we don't really need that.
|
||||
* It also uses element.ownerDocument.defaultView to handle iframes but that hasn't been required since FF 3.6.
|
||||
*/
|
||||
const styles = window.getComputedStyle(dom);
|
||||
const r = styles.getPropertyValue(property);
|
||||
// jquery-ism: If r is an empty string, check that the element is not in a document. If it isn't, return the raw value.
|
||||
// Turns out we do this a lot.
|
||||
return (r === '' && !inBody(element)) ? getUnsafeProperty(dom, property) : r;
|
||||
};
|
||||
// removed: support for dom().style[property] where prop is camel case instead of normal property name
|
||||
// empty string is what the browsers (IE11 and Chrome) return when the propertyValue doesn't exists.
|
||||
const getUnsafeProperty = (dom, property) => isSupported(dom) ? dom.style.getPropertyValue(property) : '';
|
||||
const remove = (element, property) => {
|
||||
const dom = element.dom;
|
||||
internalRemove(dom, property);
|
||||
if (is$1(getOpt(element, 'style').map(trim), '')) {
|
||||
// No more styles left, remove the style attribute as well
|
||||
remove$1(element, 'style');
|
||||
}
|
||||
};
|
||||
|
||||
const getDirection = (element) => get(element, 'direction') === 'rtl' ? 'rtl' : 'ltr';
|
||||
|
||||
const ancestor$1 = (scope, predicate, isRoot) => {
|
||||
let element = scope.dom;
|
||||
const stop = isFunction(isRoot) ? isRoot : never;
|
||||
while (element.parentNode) {
|
||||
element = element.parentNode;
|
||||
const el = SugarElement.fromDom(element);
|
||||
if (predicate(el)) {
|
||||
return Optional.some(el);
|
||||
}
|
||||
else if (stop(el)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Optional.none();
|
||||
};
|
||||
|
||||
const ancestor = (scope, selector, isRoot) => ancestor$1(scope, (e) => is(e, selector), isRoot);
|
||||
|
||||
const children$1 = (scope, predicate) => filter(children$2(scope), predicate);
|
||||
|
||||
const children = (scope, selector) =>
|
||||
// It may surprise you to learn this is exactly what JQuery does
|
||||
// TODO: Avoid all the wrapping and unwrapping
|
||||
children$1(scope, (e) => is(e, selector));
|
||||
|
||||
// if the block is a list item, we need to get the parent of the list itself
|
||||
const getNormalizedBlock = (element, isListItem) => {
|
||||
const normalizedElement = isListItem ? ancestor(element, 'ol,ul') : Optional.some(element);
|
||||
return normalizedElement.getOr(element);
|
||||
};
|
||||
const isListItem = isTag('li');
|
||||
const setDirOnElements = (dom, blocks, dir) => {
|
||||
each(blocks, (block) => {
|
||||
const blockElement = SugarElement.fromDom(block);
|
||||
const isBlockElementListItem = isListItem(blockElement);
|
||||
const normalizedBlock = getNormalizedBlock(blockElement, isBlockElementListItem);
|
||||
const attrDir = get$1(normalizedBlock, 'dir');
|
||||
const styleDir = Optional.from(dom.getStyle(normalizedBlock.dom, 'direction', false))
|
||||
.filter(isNotEmpty);
|
||||
if (attrDir === dir || styleDir.exists((d) => d === dir)) {
|
||||
// If already set to given dir, toggle dir off
|
||||
remove$1(normalizedBlock, 'dir');
|
||||
remove(normalizedBlock, 'direction');
|
||||
}
|
||||
else if (attrDir !== dir) {
|
||||
set(normalizedBlock, 'dir', dir); // Set the dir attribute to the desired direction
|
||||
if (styleDir.exists((d) => d !== dir) || getDirection(normalizedBlock) !== dir) {
|
||||
// Also set the inline direction style if it was already set or if necessary to override inherited direction
|
||||
dom.setStyle(normalizedBlock.dom, 'direction', dir);
|
||||
}
|
||||
}
|
||||
// Remove dir attr and direction style from list children
|
||||
if (isBlockElementListItem) {
|
||||
const listItems = children(normalizedBlock, 'li[dir],li[style]');
|
||||
each(listItems, (listItem) => {
|
||||
remove$1(listItem, 'dir');
|
||||
dom.setStyle(listItem.dom, 'direction', null);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
const setDir = (editor, dir) => {
|
||||
if (editor.selection.isEditable()) {
|
||||
setDirOnElements(editor.dom, editor.selection.getSelectedBlocks(), dir);
|
||||
editor.nodeChanged();
|
||||
}
|
||||
};
|
||||
|
||||
const register$1 = (editor) => {
|
||||
editor.addCommand('mceDirectionLTR', () => {
|
||||
setDir(editor, 'ltr');
|
||||
});
|
||||
editor.addCommand('mceDirectionRTL', () => {
|
||||
setDir(editor, 'rtl');
|
||||
});
|
||||
};
|
||||
|
||||
const register = (editor) => {
|
||||
const setupHandler = (dir) => (api) => {
|
||||
const nodeChangeHandler = (e) => {
|
||||
const block = editor.dom.isBlock(e.element) ? e.element : editor.dom.getParent(e.element, editor.dom.isBlock);
|
||||
if (block) {
|
||||
const direction = editor.dom.getStyle(block, 'direction') || editor.dom.getAttrib(block, 'dir');
|
||||
api.setActive(direction === dir);
|
||||
}
|
||||
};
|
||||
editor.on('NodeChange', nodeChangeHandler);
|
||||
return () => editor.off('NodeChange', nodeChangeHandler);
|
||||
};
|
||||
editor.ui.registry.addToggleButton('ltr', {
|
||||
tooltip: 'Left to right',
|
||||
icon: 'ltr',
|
||||
context: 'editable',
|
||||
onAction: () => editor.execCommand('mceDirectionLTR'),
|
||||
onSetup: setupHandler('ltr'),
|
||||
});
|
||||
editor.ui.registry.addToggleButton('rtl', {
|
||||
tooltip: 'Right to left',
|
||||
icon: 'rtl',
|
||||
context: 'editable',
|
||||
onAction: () => editor.execCommand('mceDirectionRTL'),
|
||||
onSetup: setupHandler('rtl')
|
||||
});
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global.add('directionality', (editor) => {
|
||||
register$1(editor);
|
||||
register(editor);
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
+1
-4
@@ -1,4 +1 @@
|
||||
/**
|
||||
* TinyMCE version 7.3.0 (2024-08-07)
|
||||
*/
|
||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=t=>e=>typeof e===t,o=t=>"string"===(t=>{const e=typeof t;return null===t?"null":"object"===e&&Array.isArray(t)?"array":"object"===e&&(o=r=t,(n=String).prototype.isPrototypeOf(o)||(null===(i=r.constructor)||void 0===i?void 0:i.name)===n.name)?"string":e;var o,r,n,i})(t),r=e("boolean"),n=t=>!(t=>null==t)(t),i=e("function"),s=e("number"),l=(!1,()=>false);class a{constructor(t,e){this.tag=t,this.value=e}static some(t){return new a(!0,t)}static none(){return a.singletonNone}fold(t,e){return this.tag?e(this.value):t()}isSome(){return this.tag}isNone(){return!this.tag}map(t){return this.tag?a.some(t(this.value)):a.none()}bind(t){return this.tag?t(this.value):a.none()}exists(t){return this.tag&&t(this.value)}forall(t){return!this.tag||t(this.value)}filter(t){return!this.tag||t(this.value)?this:a.none()}getOr(t){return this.tag?this.value:t}or(t){return this.tag?this:t}getOrThunk(t){return this.tag?this.value:t()}orThunk(t){return this.tag?this:t()}getOrDie(t){if(this.tag)return this.value;throw new Error(null!=t?t:"Called getOrDie on None")}static from(t){return n(t)?a.some(t):a.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(t){this.tag&&t(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}a.singletonNone=new a(!1);const u=(t,e)=>{for(let o=0,r=t.length;o<r;o++)e(t[o],o)},c=t=>{if(null==t)throw new Error("Node cannot be null or undefined");return{dom:t}},d=c,h=(t,e)=>{const o=t.dom;if(1!==o.nodeType)return!1;{const t=o;if(void 0!==t.matches)return t.matches(e);if(void 0!==t.msMatchesSelector)return t.msMatchesSelector(e);if(void 0!==t.webkitMatchesSelector)return t.webkitMatchesSelector(e);if(void 0!==t.mozMatchesSelector)return t.mozMatchesSelector(e);throw new Error("Browser lacks native selectors")}};"undefined"!=typeof window?window:Function("return this;")();const m=t=>e=>(t=>t.dom.nodeType)(e)===t,g=m(1),f=m(3),v=m(11),y=(t,e)=>{t.dom.removeAttribute(e)},p=t=>d(t.dom.host),w=t=>{const e=f(t)?t.dom.parentNode:t.dom;if(null==e||null===e.ownerDocument)return!1;const o=e.ownerDocument;return(t=>{const e=(t=>d(t.dom.getRootNode()))(t);return v(o=e)&&n(o.dom.host)?a.some(e):a.none();var o})(d(e)).fold((()=>o.body.contains(e)),(r=w,i=p,t=>r(i(t))));var r,i},b=t=>"rtl"===((t,e)=>{const o=t.dom,r=window.getComputedStyle(o).getPropertyValue(e);return""!==r||w(t)?r:((t,e)=>(t=>void 0!==t.style&&i(t.style.getPropertyValue))(t)?t.style.getPropertyValue(e):"")(o,e)})(t,"direction")?"rtl":"ltr",S=(t,e)=>((t,o)=>((t,e)=>{const o=[];for(let r=0,n=t.length;r<n;r++){const n=t[r];e(n,r)&&o.push(n)}return o})(((t,e)=>{const o=t.length,r=new Array(o);for(let n=0;n<o;n++){const o=t[n];r[n]=e(o,n)}return r})(t.dom.childNodes,d),(t=>h(t,e))))(t),N=("li",t=>g(t)&&"li"===t.dom.nodeName.toLowerCase());const A=(t,e,n)=>{u(e,(e=>{const c=d(e),m=N(c),f=((t,e)=>{return(e?(o=t,r="ol,ul",((t,e,o)=>{let n=t.dom;const s=i(o)?o:l;for(;n.parentNode;){n=n.parentNode;const t=d(n);if(h(t,r))return a.some(t);if(s(t))break}return a.none()})(o,0,n)):a.some(t)).getOr(t);var o,r,n})(c,m);var v;(v=f,(t=>a.from(t.dom.parentNode).map(d))(v).filter(g)).each((e=>{if(t.setStyle(f.dom,"direction",null),b(e)===n?y(f,"dir"):((t,e,n)=>{((t,e,n)=>{if(!(o(n)||r(n)||s(n)))throw console.error("Invalid call to Attribute.set. Key ",e,":: Value ",n,":: Element ",t),new Error("Attribute value was not simple");t.setAttribute(e,n+"")})(t.dom,e,n)})(f,"dir",n),b(f)!==n&&t.setStyle(f.dom,"direction",n),m){const e=S(f,"li[dir],li[style]");u(e,(e=>{y(e,"dir"),t.setStyle(e.dom,"direction",null)}))}}))}))},T=(t,e)=>{t.selection.isEditable()&&(A(t.dom,t.selection.getSelectedBlocks(),e),t.nodeChanged())},C=(t,e)=>o=>{const r=r=>{const n=d(r.element);o.setActive(b(n)===e),o.setEnabled(t.selection.isEditable())};return t.on("NodeChange",r),o.setEnabled(t.selection.isEditable()),()=>t.off("NodeChange",r)};t.add("directionality",(t=>{(t=>{t.addCommand("mceDirectionLTR",(()=>{T(t,"ltr")})),t.addCommand("mceDirectionRTL",(()=>{T(t,"rtl")}))})(t),(t=>{t.ui.registry.addToggleButton("ltr",{tooltip:"Left to right",icon:"ltr",onAction:()=>t.execCommand("mceDirectionLTR"),onSetup:C(t,"ltr")}),t.ui.registry.addToggleButton("rtl",{tooltip:"Right to left",icon:"rtl",onAction:()=>t.execCommand("mceDirectionRTL"),onSetup:C(t,"rtl")})})(t)}))}();
|
||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=t=>e=>typeof e===t,r=t=>"string"===(t=>{const e=typeof t;return null===t?"null":"object"===e&&Array.isArray(t)?"array":"object"===e&&(r=o=t,(n=String).prototype.isPrototypeOf(r)||o.constructor?.name===n.name)?"string":e;var r,o,n})(t),o=e("boolean"),n=t=>!(t=>null==t)(t),i=e("function"),s=e("number"),l=(t,e)=>t===e,a=()=>false;class c{tag;value;static singletonNone=new c(!1);constructor(t,e){this.tag=t,this.value=e}static some(t){return new c(!0,t)}static none(){return c.singletonNone}fold(t,e){return this.tag?e(this.value):t()}isSome(){return this.tag}isNone(){return!this.tag}map(t){return this.tag?c.some(t(this.value)):c.none()}bind(t){return this.tag?t(this.value):c.none()}exists(t){return this.tag&&t(this.value)}forall(t){return!this.tag||t(this.value)}filter(t){return!this.tag||t(this.value)?this:c.none()}getOr(t){return this.tag?this.value:t}or(t){return this.tag?this:t}getOrThunk(t){return this.tag?this.value:t()}orThunk(t){return this.tag?this:t()}getOrDie(t){if(this.tag)return this.value;throw new Error(t??"Called getOrDie on None")}static from(t){return n(t)?c.some(t):c.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(t){this.tag&&t(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}Array.prototype.slice;const u=(t,e)=>{for(let r=0,o=t.length;r<o;r++)e(t[r],r)};i(Array.from)&&Array.from;const d=(m=/^\s+|\s+$/g,t=>t.replace(m,""));var m;const h=t=>t.length>0,g=t=>{if(null==t)throw new Error("Node cannot be null or undefined");return{dom:t}},f=g,y=(t,e)=>{const r=t.dom;if(1!==r.nodeType)return!1;{const t=r;if(void 0!==t.matches)return t.matches(e);if(void 0!==t.msMatchesSelector)return t.msMatchesSelector(e);if(void 0!==t.webkitMatchesSelector)return t.webkitMatchesSelector(e);if(void 0!==t.mozMatchesSelector)return t.mozMatchesSelector(e);throw new Error("Browser lacks native selectors")}},v=t=>e=>(t=>t.dom.nodeType)(e)===t,p=v(1),w=v(3),b=v(11),S=t=>f(t.dom.host),A=(t,e)=>{const r=t.dom.getAttribute(e);return null===r?void 0:r},N=(t,e)=>{t.dom.removeAttribute(e)},T=t=>void 0!==t.style&&i(t.style.getPropertyValue),C=t=>{const e=w(t)?t.dom.parentNode:t.dom;if(null==e||null===e.ownerDocument)return!1;const r=e.ownerDocument;return(t=>{const e=(t=>f(t.dom.getRootNode()))(t);return b(r=e)&&n(r.dom.host)?c.some(e):c.none();var r})(f(e)).fold(()=>r.body.contains(e),(o=C,i=S,t=>o(i(t))));var o,i},k=t=>"rtl"===((t,e)=>{const r=t.dom,o=window.getComputedStyle(r).getPropertyValue(e);return""!==o||C(t)?o:((t,e)=>T(t)?t.style.getPropertyValue(e):"")(r,e)})(t,"direction")?"rtl":"ltr",x=(t,e)=>(t=>((t,e)=>{const r=[];for(let o=0,n=t.length;o<n;o++){const n=t[o];e(n,o)&&r.push(n)}return r})(((t,e)=>{const r=t.length,o=new Array(r);for(let n=0;n<r;n++){const r=t[n];o[n]=e(r,n)}return o})(t.dom.childNodes,f),t=>y(t,e)))(t),D=t=>p(t)&&"li"===t.dom.nodeName.toLowerCase();const O=(t,e,n)=>{u(e,e=>{const m=f(e),g=D(m),v=((t,e)=>{return(e?(r=t,o="ol,ul",((t,e,r)=>{let o=t.dom;const n=i(r)?r:a;for(;o.parentNode;){o=o.parentNode;const t=f(o);if(e(t))return c.some(t);if(n(t))break}return c.none()})(r,t=>y(t,o),n)):c.some(t)).getOr(t);var r,o,n})(m,g),p=A(v,"dir"),w=c.from(t.getStyle(v.dom,"direction",!1)).filter(h);if(p===n||w.exists(t=>t===n)?(N(v,"dir"),S="direction",((t,e)=>{T(t)&&t.style.removeProperty(e)})((b=v).dom,S),((t,e,r=l)=>t.exists(t=>r(t,e)))((t=>c.from(A(t,"style")))(b).map(d),"")&&N(b,"style")):p!==n&&(((t,e,n)=>{((t,e,n)=>{if(!(r(n)||o(n)||s(n)))throw console.error("Invalid call to Attribute.set. Key ",e,":: Value ",n,":: Element ",t),new Error("Attribute value was not simple");t.setAttribute(e,n+"")})(t.dom,e,n)})(v,"dir",n),(w.exists(t=>t!==n)||k(v)!==n)&&t.setStyle(v.dom,"direction",n)),g){const e=x(v,"li[dir],li[style]");u(e,e=>{N(e,"dir"),t.setStyle(e.dom,"direction",null)})}var b,S})},M=(t,e)=>{t.selection.isEditable()&&(O(t.dom,t.selection.getSelectedBlocks(),e),t.nodeChanged())};t.add("directionality",t=>{(t=>{t.addCommand("mceDirectionLTR",()=>{M(t,"ltr")}),t.addCommand("mceDirectionRTL",()=>{M(t,"rtl")})})(t),(t=>{const e=e=>r=>{const o=o=>{const n=t.dom.isBlock(o.element)?o.element:t.dom.getParent(o.element,t.dom.isBlock);if(n){const o=t.dom.getStyle(n,"direction")||t.dom.getAttrib(n,"dir");r.setActive(o===e)}};return t.on("NodeChange",o),()=>t.off("NodeChange",o)};t.ui.registry.addToggleButton("ltr",{tooltip:"Left to right",icon:"ltr",context:"editable",onAction:()=>t.execCommand("mceDirectionLTR"),onSetup:e("ltr")}),t.ui.registry.addToggleButton("rtl",{tooltip:"Right to left",icon:"rtl",context:"editable",onAction:()=>t.execCommand("mceDirectionRTL"),onSetup:e("rtl")})})(t)})}();
|
||||
@@ -0,0 +1,7 @@
|
||||
// Exports the "emoticons" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/emoticons')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/emoticons'
|
||||
require('./plugin.js');
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,809 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-wrapper-object-types */
|
||||
const isSimpleType = (type) => (value) => typeof value === type;
|
||||
const eq = (t) => (a) => t === a;
|
||||
const isNull = eq(null);
|
||||
const isUndefined = eq(undefined);
|
||||
const isNullable = (a) => a === null || a === undefined;
|
||||
const isNonNullable = (a) => !isNullable(a);
|
||||
const isFunction = isSimpleType('function');
|
||||
|
||||
const noop = () => { };
|
||||
const constant = (value) => {
|
||||
return () => {
|
||||
return value;
|
||||
};
|
||||
};
|
||||
const never = constant(false);
|
||||
|
||||
/**
|
||||
* The `Optional` type represents a value (of any type) that potentially does
|
||||
* not exist. Any `Optional<T>` can either be a `Some<T>` (in which case the
|
||||
* value does exist) or a `None` (in which case the value does not exist). This
|
||||
* module defines a whole lot of FP-inspired utility functions for dealing with
|
||||
* `Optional` objects.
|
||||
*
|
||||
* Comparison with null or undefined:
|
||||
* - We don't get fancy null coalescing operators with `Optional`
|
||||
* - We do get fancy helper functions with `Optional`
|
||||
* - `Optional` support nesting, and allow for the type to still be nullable (or
|
||||
* another `Optional`)
|
||||
* - There is no option to turn off strict-optional-checks like there is for
|
||||
* strict-null-checks
|
||||
*/
|
||||
class Optional {
|
||||
tag;
|
||||
value;
|
||||
// Sneaky optimisation: every instance of Optional.none is identical, so just
|
||||
// reuse the same object
|
||||
static singletonNone = new Optional(false);
|
||||
// The internal representation has a `tag` and a `value`, but both are
|
||||
// private: able to be console.logged, but not able to be accessed by code
|
||||
constructor(tag, value) {
|
||||
this.tag = tag;
|
||||
this.value = value;
|
||||
}
|
||||
// --- Identities ---
|
||||
/**
|
||||
* Creates a new `Optional<T>` that **does** contain a value.
|
||||
*/
|
||||
static some(value) {
|
||||
return new Optional(true, value);
|
||||
}
|
||||
/**
|
||||
* Create a new `Optional<T>` that **does not** contain a value. `T` can be
|
||||
* any type because we don't actually have a `T`.
|
||||
*/
|
||||
static none() {
|
||||
return Optional.singletonNone;
|
||||
}
|
||||
/**
|
||||
* Perform a transform on an `Optional` type. Regardless of whether this
|
||||
* `Optional` contains a value or not, `fold` will return a value of type `U`.
|
||||
* If this `Optional` does not contain a value, the `U` will be created by
|
||||
* calling `onNone`. If this `Optional` does contain a value, the `U` will be
|
||||
* created by calling `onSome`.
|
||||
*
|
||||
* For the FP enthusiasts in the room, this function:
|
||||
* 1. Could be used to implement all of the functions below
|
||||
* 2. Forms a catamorphism
|
||||
*/
|
||||
fold(onNone, onSome) {
|
||||
if (this.tag) {
|
||||
return onSome(this.value);
|
||||
}
|
||||
else {
|
||||
return onNone();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Determine if this `Optional` object contains a value.
|
||||
*/
|
||||
isSome() {
|
||||
return this.tag;
|
||||
}
|
||||
/**
|
||||
* Determine if this `Optional` object **does not** contain a value.
|
||||
*/
|
||||
isNone() {
|
||||
return !this.tag;
|
||||
}
|
||||
// --- Functor (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* Perform a transform on an `Optional` object, **if** there is a value. If
|
||||
* you provide a function to turn a T into a U, this is the function you use
|
||||
* to turn an `Optional<T>` into an `Optional<U>`. If this **does** contain
|
||||
* a value then the output will also contain a value (that value being the
|
||||
* output of `mapper(this.value)`), and if this **does not** contain a value
|
||||
* then neither will the output.
|
||||
*/
|
||||
map(mapper) {
|
||||
if (this.tag) {
|
||||
return Optional.some(mapper(this.value));
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Monad (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* Perform a transform on an `Optional` object, **if** there is a value.
|
||||
* Unlike `map`, here the transform itself also returns an `Optional`.
|
||||
*/
|
||||
bind(binder) {
|
||||
if (this.tag) {
|
||||
return binder(this.value);
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Traversable (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* For a given predicate, this function finds out if there **exists** a value
|
||||
* inside this `Optional` object that meets the predicate. In practice, this
|
||||
* means that for `Optional`s that do not contain a value it returns false (as
|
||||
* no predicate-meeting value exists).
|
||||
*/
|
||||
exists(predicate) {
|
||||
return this.tag && predicate(this.value);
|
||||
}
|
||||
/**
|
||||
* For a given predicate, this function finds out if **all** the values inside
|
||||
* this `Optional` object meet the predicate. In practice, this means that
|
||||
* for `Optional`s that do not contain a value it returns true (as all 0
|
||||
* objects do meet the predicate).
|
||||
*/
|
||||
forall(predicate) {
|
||||
return !this.tag || predicate(this.value);
|
||||
}
|
||||
filter(predicate) {
|
||||
if (!this.tag || predicate(this.value)) {
|
||||
return this;
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Getters ---
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value.
|
||||
*/
|
||||
getOr(replacement) {
|
||||
return this.tag ? this.value : replacement;
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value. Unlike `getOr`, in this method the `replacement` object is also
|
||||
* `Optional` - meaning that this method will always return an `Optional`.
|
||||
*/
|
||||
or(replacement) {
|
||||
return this.tag ? this : replacement;
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value. Unlike `getOr`, in this method the `replacement` value is
|
||||
* "thunked" - that is to say that you don't pass a value to `getOrThunk`, you
|
||||
* pass a function which (if called) will **return** the `value` you want to
|
||||
* use.
|
||||
*/
|
||||
getOrThunk(thunk) {
|
||||
return this.tag ? this.value : thunk();
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided Optional object does not contain a
|
||||
* value.
|
||||
*
|
||||
* Unlike `or`, in this method the `replacement` value is "thunked" - that is
|
||||
* to say that you don't pass a value to `orThunk`, you pass a function which
|
||||
* (if called) will **return** the `value` you want to use.
|
||||
*
|
||||
* Unlike `getOrThunk`, in this method the `replacement` value is also
|
||||
* `Optional`, meaning that this method will always return an `Optional`.
|
||||
*/
|
||||
orThunk(thunk) {
|
||||
return this.tag ? this : thunk();
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, throwing an
|
||||
* exception if the provided `Optional` object does not contain a value.
|
||||
*
|
||||
* WARNING:
|
||||
* You should only be using this function if you know that the `Optional`
|
||||
* object **is not** empty (otherwise you're throwing exceptions in production
|
||||
* code, which is bad).
|
||||
*
|
||||
* In tests this is more acceptable.
|
||||
*
|
||||
* Prefer other methods to this, such as `.each`.
|
||||
*/
|
||||
getOrDie(message) {
|
||||
if (!this.tag) {
|
||||
throw new Error(message ?? 'Called getOrDie on None');
|
||||
}
|
||||
else {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
// --- Interop with null and undefined ---
|
||||
/**
|
||||
* Creates an `Optional` value from a nullable (or undefined-able) input.
|
||||
* Null, or undefined, is converted to `None`, and anything else is converted
|
||||
* to `Some`.
|
||||
*/
|
||||
static from(value) {
|
||||
return isNonNullable(value) ? Optional.some(value) : Optional.none();
|
||||
}
|
||||
/**
|
||||
* Converts an `Optional` to a nullable type, by getting the value if it
|
||||
* exists, or returning `null` if it does not.
|
||||
*/
|
||||
getOrNull() {
|
||||
return this.tag ? this.value : null;
|
||||
}
|
||||
/**
|
||||
* Converts an `Optional` to an undefined-able type, by getting the value if
|
||||
* it exists, or returning `undefined` if it does not.
|
||||
*/
|
||||
getOrUndefined() {
|
||||
return this.value;
|
||||
}
|
||||
// --- Utilities ---
|
||||
/**
|
||||
* If the `Optional` contains a value, perform an action on that value.
|
||||
* Unlike the rest of the methods on this type, `.each` has side-effects. If
|
||||
* you want to transform an `Optional<T>` **into** something, then this is not
|
||||
* the method for you. If you want to use an `Optional<T>` to **do**
|
||||
* something, then this is the method for you - provided you're okay with not
|
||||
* doing anything in the case where the `Optional` doesn't have a value inside
|
||||
* it. If you're not sure whether your use-case fits into transforming
|
||||
* **into** something or **doing** something, check whether it has a return
|
||||
* value. If it does, you should be performing a transform.
|
||||
*/
|
||||
each(worker) {
|
||||
if (this.tag) {
|
||||
worker(this.value);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Turn the `Optional` object into an array that contains all of the values
|
||||
* stored inside the `Optional`. In practice, this means the output will have
|
||||
* either 0 or 1 elements.
|
||||
*/
|
||||
toArray() {
|
||||
return this.tag ? [this.value] : [];
|
||||
}
|
||||
/**
|
||||
* Turn the `Optional` object into a string for debugging or printing. Not
|
||||
* recommended for production code, but good for debugging. Also note that
|
||||
* these days an `Optional` object can be logged to the console directly, and
|
||||
* its inner value (if it exists) will be visible.
|
||||
*/
|
||||
toString() {
|
||||
return this.tag ? `some(${this.value})` : 'none()';
|
||||
}
|
||||
}
|
||||
|
||||
const nativeSlice = Array.prototype.slice;
|
||||
const exists = (xs, pred) => {
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
if (pred(x, i)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const map$1 = (xs, f) => {
|
||||
// pre-allocating array size when it's guaranteed to be known
|
||||
// http://jsperf.com/push-allocated-vs-dynamic/22
|
||||
const len = xs.length;
|
||||
const r = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = xs[i];
|
||||
r[i] = f(x, i);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
// Unwound implementing other functions in terms of each.
|
||||
// The code size is roughly the same, and it should allow for better optimisation.
|
||||
// const each = function<T, U>(xs: T[], f: (x: T, i?: number, xs?: T[]) => void): void {
|
||||
const each$1 = (xs, f) => {
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
f(x, i);
|
||||
}
|
||||
};
|
||||
isFunction(Array.from) ? Array.from : (x) => nativeSlice.call(x);
|
||||
|
||||
// There are many variations of Object iteration that are faster than the 'for-in' style:
|
||||
// http://jsperf.com/object-keys-iteration/107
|
||||
//
|
||||
// Use the native keys if it is available (IE9+), otherwise fall back to manually filtering
|
||||
const keys = Object.keys;
|
||||
const hasOwnProperty = Object.hasOwnProperty;
|
||||
const each = (obj, f) => {
|
||||
const props = keys(obj);
|
||||
for (let k = 0, len = props.length; k < len; k++) {
|
||||
const i = props[k];
|
||||
const x = obj[i];
|
||||
f(x, i);
|
||||
}
|
||||
};
|
||||
const map = (obj, f) => {
|
||||
return tupleMap(obj, (x, i) => ({
|
||||
k: i,
|
||||
v: f(x, i)
|
||||
}));
|
||||
};
|
||||
const tupleMap = (obj, f) => {
|
||||
const r = {};
|
||||
each(obj, (x, i) => {
|
||||
const tuple = f(x, i);
|
||||
r[tuple.k] = tuple.v;
|
||||
});
|
||||
return r;
|
||||
};
|
||||
const has = (obj, key) => hasOwnProperty.call(obj, key);
|
||||
|
||||
const Cell = (initial) => {
|
||||
let value = initial;
|
||||
const get = () => {
|
||||
return value;
|
||||
};
|
||||
const set = (v) => {
|
||||
value = v;
|
||||
};
|
||||
return {
|
||||
get,
|
||||
set
|
||||
};
|
||||
};
|
||||
|
||||
const shallow = (old, nu) => {
|
||||
return nu;
|
||||
};
|
||||
const baseMerge = (merger) => {
|
||||
return (...objects) => {
|
||||
if (objects.length === 0) {
|
||||
throw new Error(`Can't merge zero objects`);
|
||||
}
|
||||
const ret = {};
|
||||
for (let j = 0; j < objects.length; j++) {
|
||||
const curObject = objects[j];
|
||||
for (const key in curObject) {
|
||||
if (has(curObject, key)) {
|
||||
ret[key] = merger(ret[key], curObject[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
};
|
||||
const merge = baseMerge(shallow);
|
||||
|
||||
const singleton = (doRevoke) => {
|
||||
const subject = Cell(Optional.none());
|
||||
const revoke = () => subject.get().each(doRevoke);
|
||||
const clear = () => {
|
||||
revoke();
|
||||
subject.set(Optional.none());
|
||||
};
|
||||
const isSet = () => subject.get().isSome();
|
||||
const get = () => subject.get();
|
||||
const set = (s) => {
|
||||
revoke();
|
||||
subject.set(Optional.some(s));
|
||||
};
|
||||
return {
|
||||
clear,
|
||||
isSet,
|
||||
get,
|
||||
set
|
||||
};
|
||||
};
|
||||
const value = () => {
|
||||
const subject = singleton(noop);
|
||||
const on = (f) => subject.get().each(f);
|
||||
return {
|
||||
...subject,
|
||||
on
|
||||
};
|
||||
};
|
||||
|
||||
const checkRange = (str, substr, start) => substr === '' || str.length >= substr.length && str.substr(start, start + substr.length) === substr;
|
||||
const contains = (str, substr, start = 0, end) => {
|
||||
const idx = str.indexOf(substr, start);
|
||||
if (idx !== -1) {
|
||||
return isUndefined(end) ? true : idx + substr.length <= end;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
/** Does 'str' start with 'prefix'?
|
||||
* Note: all strings start with the empty string.
|
||||
* More formally, for all strings x, startsWith(x, "").
|
||||
* This is so that for all strings x and y, startsWith(y + x, y)
|
||||
*/
|
||||
const startsWith = (str, prefix) => {
|
||||
return checkRange(str, prefix, 0);
|
||||
};
|
||||
|
||||
// Run a function fn after rate ms. If another invocation occurs
|
||||
// during the time it is waiting, reschedule the function again
|
||||
// with the new arguments.
|
||||
const last = (fn, rate) => {
|
||||
let timer = null;
|
||||
const cancel = () => {
|
||||
if (!isNull(timer)) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
const throttle = (...args) => {
|
||||
cancel();
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
fn.apply(null, args);
|
||||
}, rate);
|
||||
};
|
||||
return {
|
||||
cancel,
|
||||
throttle
|
||||
};
|
||||
};
|
||||
|
||||
const insertEmoticon = (editor, ch) => {
|
||||
editor.insertContent(ch);
|
||||
};
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.Resource');
|
||||
|
||||
const DEFAULT_ID = 'tinymce.plugins.emoticons';
|
||||
const option = (name) => (editor) => editor.options.get(name);
|
||||
const register$2 = (editor, pluginUrl) => {
|
||||
const registerOption = editor.options.register;
|
||||
registerOption('emoticons_database', {
|
||||
processor: 'string',
|
||||
default: 'emojis'
|
||||
});
|
||||
registerOption('emoticons_database_url', {
|
||||
processor: 'string',
|
||||
default: `${pluginUrl}/js/${getEmojiDatabase(editor)}${editor.suffix}.js`
|
||||
});
|
||||
registerOption('emoticons_database_id', {
|
||||
processor: 'string',
|
||||
default: DEFAULT_ID
|
||||
});
|
||||
registerOption('emoticons_append', {
|
||||
processor: 'object',
|
||||
default: {}
|
||||
});
|
||||
registerOption('emoticons_images_url', {
|
||||
processor: 'string',
|
||||
default: 'https://cdnjs.cloudflare.com/ajax/libs/twemoji/15.1.0/72x72/'
|
||||
});
|
||||
};
|
||||
const getEmojiDatabase = option('emoticons_database');
|
||||
const getEmojiDatabaseUrl = option('emoticons_database_url');
|
||||
const getEmojiDatabaseId = option('emoticons_database_id');
|
||||
const getAppendedEmoji = option('emoticons_append');
|
||||
const getEmojiImageUrl = option('emoticons_images_url');
|
||||
|
||||
const ALL_CATEGORY = 'All';
|
||||
const categoryNameMap = {
|
||||
symbols: 'Symbols',
|
||||
people: 'People',
|
||||
animals_and_nature: 'Animals and Nature',
|
||||
food_and_drink: 'Food and Drink',
|
||||
activity: 'Activity',
|
||||
travel_and_places: 'Travel and Places',
|
||||
objects: 'Objects',
|
||||
flags: 'Flags',
|
||||
user: 'User Defined'
|
||||
};
|
||||
const translateCategory = (categories, name) => has(categories, name) ? categories[name] : name;
|
||||
const getUserDefinedEmoji = (editor) => {
|
||||
const userDefinedEmoticons = getAppendedEmoji(editor);
|
||||
return map(userDefinedEmoticons, (value) =>
|
||||
// Set some sane defaults for the custom emoji entry
|
||||
({ keywords: [], category: 'user', ...value }));
|
||||
};
|
||||
// TODO: Consider how to share this loading across different editors
|
||||
const initDatabase = (editor, databaseUrl, databaseId) => {
|
||||
const categories = value();
|
||||
const all = value();
|
||||
const emojiImagesUrl = getEmojiImageUrl(editor);
|
||||
const getEmoji = (lib) => {
|
||||
// Note: This is a little hacky, but the database doesn't provide a way for us to tell what sort of database is being used
|
||||
if (startsWith(lib.char, '<img')) {
|
||||
return lib.char.replace(/src="([^"]+)"/, (match, url) => `src="${emojiImagesUrl}${url}"`);
|
||||
}
|
||||
else {
|
||||
return lib.char;
|
||||
}
|
||||
};
|
||||
const processEmojis = (emojis) => {
|
||||
const cats = {};
|
||||
const everything = [];
|
||||
each(emojis, (lib, title) => {
|
||||
const entry = {
|
||||
// Omitting fitzpatrick_scale
|
||||
title,
|
||||
keywords: lib.keywords,
|
||||
char: getEmoji(lib),
|
||||
category: translateCategory(categoryNameMap, lib.category)
|
||||
};
|
||||
const current = cats[entry.category] !== undefined ? cats[entry.category] : [];
|
||||
cats[entry.category] = current.concat([entry]);
|
||||
everything.push(entry);
|
||||
});
|
||||
categories.set(cats);
|
||||
all.set(everything);
|
||||
};
|
||||
editor.on('init', () => {
|
||||
global.load(databaseId, databaseUrl).then((emojis) => {
|
||||
const userEmojis = getUserDefinedEmoji(editor);
|
||||
processEmojis(merge(emojis, userEmojis));
|
||||
}, (err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Failed to load emojis: ${err}`);
|
||||
categories.set({});
|
||||
all.set([]);
|
||||
});
|
||||
});
|
||||
const listCategory = (category) => {
|
||||
if (category === ALL_CATEGORY) {
|
||||
return listAll();
|
||||
}
|
||||
return categories.get().bind((cats) => Optional.from(cats[category])).getOr([]);
|
||||
};
|
||||
const listAll = () => all.get().getOr([]);
|
||||
const listCategories = () =>
|
||||
// TODO: Category key order should be adjusted to match the standard
|
||||
[ALL_CATEGORY].concat(keys(categories.get().getOr({})));
|
||||
const waitForLoad = () => {
|
||||
if (hasLoaded()) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
else {
|
||||
return new Promise((resolve, reject) => {
|
||||
let numRetries = 15;
|
||||
const interval = setInterval(() => {
|
||||
if (hasLoaded()) {
|
||||
clearInterval(interval);
|
||||
resolve(true);
|
||||
}
|
||||
else {
|
||||
numRetries--;
|
||||
if (numRetries < 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Could not load emojis from url: ' + databaseUrl);
|
||||
clearInterval(interval);
|
||||
reject(false);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
};
|
||||
const hasLoaded = () => categories.isSet() && all.isSet();
|
||||
return {
|
||||
listCategories,
|
||||
hasLoaded,
|
||||
waitForLoad,
|
||||
listAll,
|
||||
listCategory
|
||||
};
|
||||
};
|
||||
|
||||
const emojiMatches = (emoji, lowerCasePattern) => contains(emoji.title.toLowerCase(), lowerCasePattern) ||
|
||||
exists(emoji.keywords, (k) => contains(k.toLowerCase(), lowerCasePattern));
|
||||
const emojisFrom = (list, pattern, maxResults) => {
|
||||
const matches = [];
|
||||
const lowerCasePattern = pattern.toLowerCase();
|
||||
const reachedLimit = maxResults.fold(() => never, (max) => (size) => size >= max);
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
// TODO: more intelligent search by showing title matches at the top, keyword matches after that (use two arrays and concat at the end)
|
||||
if (pattern.length === 0 || emojiMatches(list[i], lowerCasePattern)) {
|
||||
matches.push({
|
||||
value: list[i].char,
|
||||
text: list[i].title,
|
||||
icon: list[i].char
|
||||
});
|
||||
if (reachedLimit(matches.length)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
|
||||
const patternName = 'pattern';
|
||||
const open = (editor, database) => {
|
||||
const initialState = {
|
||||
pattern: '',
|
||||
results: emojisFrom(database.listAll(), '', Optional.some(300))
|
||||
};
|
||||
const currentTab = Cell(ALL_CATEGORY);
|
||||
const scan = (dialogApi) => {
|
||||
const dialogData = dialogApi.getData();
|
||||
const category = currentTab.get();
|
||||
const candidates = database.listCategory(category);
|
||||
const results = emojisFrom(candidates, dialogData[patternName], category === ALL_CATEGORY ? Optional.some(300) : Optional.none());
|
||||
dialogApi.setData({
|
||||
results
|
||||
});
|
||||
};
|
||||
const updateFilter = last((dialogApi) => {
|
||||
scan(dialogApi);
|
||||
}, 200);
|
||||
const searchField = {
|
||||
label: 'Search',
|
||||
type: 'input',
|
||||
name: patternName
|
||||
};
|
||||
const resultsField = {
|
||||
type: 'collection',
|
||||
name: 'results'
|
||||
// TODO TINY-3229 implement collection columns properly
|
||||
// columns: 'auto'
|
||||
};
|
||||
const getInitialState = () => {
|
||||
const body = {
|
||||
type: 'tabpanel',
|
||||
// All tabs have the same fields.
|
||||
tabs: map$1(database.listCategories(), (cat) => ({
|
||||
title: cat,
|
||||
name: cat,
|
||||
items: [searchField, resultsField]
|
||||
}))
|
||||
};
|
||||
return {
|
||||
title: 'Emojis',
|
||||
size: 'normal',
|
||||
body,
|
||||
initialData: initialState,
|
||||
onTabChange: (dialogApi, details) => {
|
||||
currentTab.set(details.newTabName);
|
||||
updateFilter.throttle(dialogApi);
|
||||
},
|
||||
onChange: updateFilter.throttle,
|
||||
onAction: (dialogApi, actionData) => {
|
||||
if (actionData.name === 'results') {
|
||||
insertEmoticon(editor, actionData.value);
|
||||
dialogApi.close();
|
||||
}
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
type: 'cancel',
|
||||
text: 'Close',
|
||||
primary: true
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
const dialogApi = editor.windowManager.open(getInitialState());
|
||||
dialogApi.focus(patternName);
|
||||
if (!database.hasLoaded()) {
|
||||
dialogApi.block('Loading emojis...');
|
||||
database.waitForLoad().then(() => {
|
||||
dialogApi.redial(getInitialState());
|
||||
updateFilter.throttle(dialogApi);
|
||||
dialogApi.focus(patternName);
|
||||
dialogApi.unblock();
|
||||
}).catch((_err) => {
|
||||
dialogApi.redial({
|
||||
title: 'Emojis',
|
||||
body: {
|
||||
type: 'panel',
|
||||
items: [
|
||||
{
|
||||
type: 'alertbanner',
|
||||
level: 'error',
|
||||
icon: 'warning',
|
||||
text: 'Could not load emojis'
|
||||
}
|
||||
]
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
type: 'cancel',
|
||||
text: 'Close',
|
||||
primary: true
|
||||
}
|
||||
],
|
||||
initialData: {
|
||||
pattern: '',
|
||||
results: []
|
||||
}
|
||||
});
|
||||
dialogApi.focus(patternName);
|
||||
dialogApi.unblock();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const register$1 = (editor, database) => {
|
||||
editor.addCommand('mceEmoticons', () => open(editor, database));
|
||||
};
|
||||
|
||||
const setup = (editor) => {
|
||||
editor.on('PreInit', () => {
|
||||
editor.parser.addAttributeFilter('data-emoticon', (nodes) => {
|
||||
each$1(nodes, (node) => {
|
||||
node.attr('data-mce-resize', 'false');
|
||||
node.attr('data-mce-placeholder', '1');
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const init = (editor, database) => {
|
||||
editor.ui.registry.addAutocompleter('emoticons', {
|
||||
trigger: ':',
|
||||
columns: 'auto',
|
||||
minChars: 2,
|
||||
fetch: (pattern, maxResults) => database.waitForLoad().then(() => {
|
||||
const candidates = database.listAll();
|
||||
return emojisFrom(candidates, pattern, Optional.some(maxResults));
|
||||
}),
|
||||
onAction: (autocompleteApi, rng, value) => {
|
||||
editor.selection.setRng(rng);
|
||||
editor.insertContent(value);
|
||||
autocompleteApi.hide();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSetupEditable = (editor) => (api) => {
|
||||
const nodeChanged = () => {
|
||||
api.setEnabled(editor.selection.isEditable());
|
||||
};
|
||||
editor.on('NodeChange', nodeChanged);
|
||||
nodeChanged();
|
||||
return () => {
|
||||
editor.off('NodeChange', nodeChanged);
|
||||
};
|
||||
};
|
||||
const register = (editor) => {
|
||||
const onAction = () => editor.execCommand('mceEmoticons');
|
||||
editor.ui.registry.addButton('emoticons', {
|
||||
tooltip: 'Emojis',
|
||||
icon: 'emoji',
|
||||
onAction,
|
||||
onSetup: onSetupEditable(editor)
|
||||
});
|
||||
editor.ui.registry.addMenuItem('emoticons', {
|
||||
text: 'Emojis...',
|
||||
icon: 'emoji',
|
||||
onAction,
|
||||
onSetup: onSetupEditable(editor)
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* This class contains all core logic for the emoticons plugin.
|
||||
*
|
||||
* @class tinymce.emoticons.Plugin
|
||||
* @private
|
||||
*/
|
||||
var Plugin = () => {
|
||||
global$1.add('emoticons', (editor, pluginUrl) => {
|
||||
register$2(editor, pluginUrl);
|
||||
const databaseUrl = getEmojiDatabaseUrl(editor);
|
||||
const databaseId = getEmojiDatabaseId(editor);
|
||||
const database = initDatabase(editor, databaseUrl, databaseId);
|
||||
register$1(editor, database);
|
||||
register(editor);
|
||||
init(editor, database);
|
||||
setup(editor);
|
||||
return {
|
||||
getAllEmojis: () => database.waitForLoad().then(() => database.listAll())
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
+1
-4
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
// Exports the "fullscreen" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/fullscreen')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/fullscreen'
|
||||
require('./plugin.js');
|
||||
File diff suppressed because it is too large
Load Diff
+1
-4
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
// Exports the "help" plugin for usage with module loaders
|
||||
// Usage:
|
||||
// CommonJS:
|
||||
// require('tinymce/plugins/help')
|
||||
// ES2015:
|
||||
// import 'tinymce/plugins/help'
|
||||
require('./plugin.js');
|
||||
@@ -0,0 +1,93 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.bg-BG',
|
||||
'<h1>Начало на навигацията с клавиатурата</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Фокусиране върху лентата с менюта</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Фокусиране върху лентата с инструменти</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Фокусиране върху долния колонтитул</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Фокусиране на известието</dt>\n' +
|
||||
' <dd>Windows или Linux: Alt+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>Фокусиране върху контекстуалната лента с инструменти</dt>\n' +
|
||||
' <dd>Windows, Linux или macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Навигацията ще започне с първия елемент на ПИ, който ще бъде маркиран или подчертан в случая на първия елемент в\n' +
|
||||
' пътя до елемента в долния колонтитул.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране между раздели на ПИ</h1>\n' +
|
||||
'\n' +
|
||||
'<p>За да преминете от един раздел на ПИ към следващия, натиснете <strong>Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>За да преминете от един раздел на ПИ към предишния, натиснете <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Редът за <strong>обхождане с табулация</strong> на тези раздели на ПИ е:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Лентата с менюта</li>\n' +
|
||||
' <li>Всяка група на лентата с инструменти</li>\n' +
|
||||
' <li>Страничната лента</li>\n' +
|
||||
' <li>Пътят до елемента в долния колонтитул</li>\n' +
|
||||
' <li>Бутонът за превключване на броя на думите в долния колонтитул</li>\n' +
|
||||
' <li>Връзката за търговска марка в долния колонтитул</li>\n' +
|
||||
' <li>Манипулаторът за преоразмеряване на редактора в долния колонтитул</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Ако някой раздел на ПИ липсва, той се пропуска.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Ако долният колонтитул има фокус за навигация с клавиатурата и няма странична лента, натискането на <strong>Shift+Tab</strong>\n' +
|
||||
' премества фокуса към първата група на лентата с инструменти, а не към последната.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране в разделите на ПИ</h1>\n' +
|
||||
'\n' +
|
||||
'<p>За да преминете от един елемент на ПИ към следващия, натиснете съответния клавиш със <strong>стрелка</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>С клавишите със стрелка <strong>наляво</strong> и <strong>надясно</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>се придвижвате между менютата в лентата с менюто;</li>\n' +
|
||||
' <li>отваряте подменю в меню;</li>\n' +
|
||||
' <li>се придвижвате между бутоните в група на лентата с инструменти;</li>\n' +
|
||||
' <li>се придвижвате между елементи в пътя до елемент в долния колонтитул.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>С клавишите със стрелка <strong>надолу</strong> и <strong>нагоре</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>се придвижвате между елементите от менюто в дадено меню;</li>\n' +
|
||||
' <li>се придвижвате между елементите в изскачащо меню на лентата с инструменти.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Клавишите със <strong>стрелки</strong> се придвижват в рамките на фокусирания раздел на ПИ.</p>\n' +
|
||||
'\n' +
|
||||
'<p>За да затворите отворено меню, подменю или изскачащо меню, натиснете клавиша <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Ако текущият фокус е върху „горната част“ на конкретен раздел на ПИ, натискането на клавиша <strong>Esc</strong> също излиза\n' +
|
||||
' напълно от навигацията с клавиатурата.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Изпълнение на елемент от менюто или бутон от лентата с инструменти</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Когато желаният елемент от менюто или бутон от лентата с инструменти е маркиран, натиснете <strong>Return</strong>, <strong>Enter</strong>\n' +
|
||||
' или <strong>клавиша за интервал</strong>, за да изпълните елемента.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране в диалогови прозорци без раздели</h1>\n' +
|
||||
'\n' +
|
||||
'<p>В диалоговите прозорци без раздели първият интерактивен компонент се фокусира, когато се отвори диалоговият прозорец.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Навигирайте между интерактивните компоненти на диалоговия прозорец, като натиснете <strong>Tab</strong> или <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Навигиране в диалогови прозорци с раздели</h1>\n' +
|
||||
'\n' +
|
||||
'<p>В диалоговите прозорци с раздели първият бутон в менюто с раздели се фокусира, когато се отвори диалоговият прозорец.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Навигирайте между интерактивните компоненти на този диалогов раздел, като натиснете <strong>Tab</strong> или\n' +
|
||||
' <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Превключете към друг диалогов раздел, като фокусирате върху менюто с раздели и след това натиснете съответния клавиш със <strong>стрелка</strong>,\n' +
|
||||
' за да преминете през наличните раздели.</p>\n');
|
||||
@@ -0,0 +1,93 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.fr-FR',
|
||||
'<h1>Débuter la navigation au clavier</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Cibler la barre du menu</dt>\n' +
|
||||
' <dd>Windows ou Linux : Alt+F9</dd>\n' +
|
||||
' <dd>macOS : ⌥F9</dd>\n' +
|
||||
" <dt>Cibler la barre d'outils</dt>\n" +
|
||||
' <dd>Windows ou Linux : Alt+F10</dd>\n' +
|
||||
' <dd>macOS : ⌥F10</dd>\n' +
|
||||
' <dt>Cibler le pied de page</dt>\n' +
|
||||
' <dd>Windows ou Linux : Alt+F11</dd>\n' +
|
||||
' <dd>macOS : ⌥F11</dd>\n' +
|
||||
' <dt>Cibler la notification</dt>\n' +
|
||||
' <dd>Windows ou Linux : Alt+F12</dd>\n' +
|
||||
' <dd>macOS : ⌥F12</dd>\n' +
|
||||
" <dt>Cibler une barre d'outils contextuelle</dt>\n" +
|
||||
' <dd>Windows, Linux ou macOS : Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
"<p>La navigation débutera sur le premier élément de l'interface utilisateur, qui sera mis en surbrillance ou bien souligné dans le cas du premier élément du\n" +
|
||||
" chemin d'éléments du pied de page.</p>\n" +
|
||||
'\n' +
|
||||
"<h1>Naviguer entre les sections de l'interface utilisateur</h1>\n" +
|
||||
'\n' +
|
||||
"<p>Pour passer d'une section de l'interface utilisateur à la suivante, appuyez sur <strong>Tabulation</strong>.</p>\n" +
|
||||
'\n' +
|
||||
"<p>Pour passer d'une section de l'interface utilisateur à la précédente, appuyez sur <strong>Maj+Tabulation</strong>.</p>\n" +
|
||||
'\n' +
|
||||
"<p>L'ordre de <strong>Tabulation</strong> de ces sections de l'interface utilisateur est le suivant :</p>\n" +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Barre du menu</li>\n' +
|
||||
" <li>Chaque groupe de barres d'outils</li>\n" +
|
||||
' <li>Barre latérale</li>\n' +
|
||||
" <li>Chemin d'éléments du pied de page</li>\n" +
|
||||
" <li>Bouton d'activation du compteur de mots dans le pied de page</li>\n" +
|
||||
' <li>Lien de marque dans le pied de page</li>\n' +
|
||||
" <li>Poignée de redimensionnement de l'éditeur dans le pied de page</li>\n" +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
"<p>Si une section de l'interface utilisateur n'est pas présente, elle sera ignorée.</p>\n" +
|
||||
'\n' +
|
||||
"<p>Si le pied de page comporte un ciblage par navigation au clavier et qu'il n'y a aucune barre latérale visible, appuyer sur <strong>Maj+Tabulation</strong>\n" +
|
||||
" déplace le ciblage vers le premier groupe de barres d'outils et non le dernier.</p>\n" +
|
||||
'\n' +
|
||||
"<h1>Naviguer au sein des sections de l'interface utilisateur</h1>\n" +
|
||||
'\n' +
|
||||
"<p>Pour passer d'un élément de l'interface utilisateur au suivant, appuyez sur la <strong>Flèche</strong> appropriée.</p>\n" +
|
||||
'\n' +
|
||||
'<p>Les touches fléchées <strong>Gauche</strong> et <strong>Droite</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>se déplacent entre les menus de la barre des menus.</li>\n' +
|
||||
" <li>ouvrent un sous-menu au sein d'un menu.</li>\n" +
|
||||
" <li>se déplacent entre les boutons d'un groupe de barres d'outils.</li>\n" +
|
||||
" <li>se déplacent entre les éléments du chemin d'éléments du pied de page.</li>\n" +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Les touches fléchées <strong>Bas</strong> et <strong>Haut</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
" <li>se déplacent entre les éléments de menu au sein d'un menu.</li>\n" +
|
||||
" <li>se déplacent entre les éléments au sein d'un menu contextuel de barre d'outils.</li>\n" +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
"<p>Les <strong>Flèches</strong> parcourent la section de l'interface utilisateur ciblée.</p>\n" +
|
||||
'\n' +
|
||||
'<p>Pour fermer un menu ouvert, un sous-menu ouvert ou un menu contextuel ouvert, appuyez sur <strong>Echap</strong>.</p>\n' +
|
||||
'\n' +
|
||||
"<p>Si l'actuel ciblage se trouve en « haut » d'une section spécifique de l'interface utilisateur, appuyer sur <strong>Echap</strong> permet également de quitter\n" +
|
||||
' entièrement la navigation au clavier.</p>\n' +
|
||||
'\n' +
|
||||
"<h1>Exécuter un élément de menu ou un bouton de barre d'outils</h1>\n" +
|
||||
'\n' +
|
||||
"<p>Lorsque l'élément de menu ou le bouton de barre d'outils désiré est mis en surbrillance, appuyez sur la touche <strong>Retour arrière</strong>, <strong>Entrée</strong>\n" +
|
||||
" ou la <strong>Barre d'espace</strong> pour exécuter l'élément.</p>\n" +
|
||||
'\n' +
|
||||
'<h1>Naviguer au sein de dialogues sans onglets</h1>\n' +
|
||||
'\n' +
|
||||
"<p>Dans les dialogues sans onglets, le premier composant interactif est ciblé lorsque le dialogue s'ouvre.</p>\n" +
|
||||
'\n' +
|
||||
'<p>Naviguez entre les composants du dialogue interactif en appuyant sur <strong>Tabulation</strong> ou <strong>Maj+Tabulation</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Naviguer au sein de dialogues avec onglets</h1>\n' +
|
||||
'\n' +
|
||||
"<p>Dans les dialogues avec onglets, le premier bouton du menu de l'onglet est ciblé lorsque le dialogue s'ouvre.</p>\n" +
|
||||
'\n' +
|
||||
'<p>Naviguez entre les composants interactifs de cet onglet de dialogue en appuyant sur <strong>Tabulation</strong> ou\n' +
|
||||
' <strong>Maj+Tabulation</strong>.</p>\n' +
|
||||
'\n' +
|
||||
"<p>Passez à un autre onglet de dialogue en ciblant le menu de l'onglet et en appuyant sur la <strong>Flèche</strong>\n" +
|
||||
' appropriée pour parcourir les onglets disponibles.</p>\n');
|
||||
@@ -0,0 +1,93 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.he-IL',
|
||||
'<h1>התחל ניווט במקלדת</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>התמקד בשורת התפריטים</dt>\n' +
|
||||
' <dd>Windows או Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>העבר מיקוד לסרגל הכלים</dt>\n' +
|
||||
' <dd>Windows או Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>העבר מיקוד לכותרת התחתונה</dt>\n' +
|
||||
' <dd>Windows או Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>העבר מיקוד להודעה</dt>\n' +
|
||||
' <dd>Windows או Linux: Alt+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>העבר מיקוד לסרגל כלים הקשרי</dt>\n' +
|
||||
' <dd>Windows, Linux או macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>הניווט יתחיל ברכיב הראשון במשך, שיודגש או שיהיה מתחתיו קו תחתון במקרה של הפריט הראשון\n' +
|
||||
' הנתיב של רכיב הכותרת התחתונה.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>עבור בין מקטעים במסך</h1>\n' +
|
||||
'\n' +
|
||||
'<p>כדי לעבור בין המקטעים במסך, הקש <strong>Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>כדי לעבור למקטע הקודם במסך, הקש <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>הסדר מבחינת מקש <strong>Tab</strong> של הרכיבים במסך:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>שורת התפריטים</li>\n' +
|
||||
' <li>כל קבוצה בסרגל הכלים</li>\n' +
|
||||
' <li>הסרגל הצידי</li>\n' +
|
||||
' <li>נתיב של רכיב בכותרת התחתונה</li>\n' +
|
||||
' <li>לחצן לספירת מילים בכותרת התחתונה</li>\n' +
|
||||
' <li>קישור של המותג בכותרת התחתונה</li>\n' +
|
||||
' <li>ידית לשינוי גודל עבור העורך בכותרת התחתונה</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>אם רכיב כלשהו במסך לא מופיע, המערכת תדלג עליו.</p>\n' +
|
||||
'\n' +
|
||||
'<p>אם בכותרת התחתונה יש מיקוד של ניווט במקלדת, ולא מופיע סרגל בצד, יש להקיש <strong>Shift+Tab</strong>\n' +
|
||||
' מעביר את המיקוד לקבוצה הראשונה בסרגל הכלים, לא האחרונה.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>עבור בתוך מקטעים במסך</h1>\n' +
|
||||
'\n' +
|
||||
'<p>כדי לעבור מרכיב אחד לרכיב אחר במסך, הקש על מקש <strong>החץ</strong> המתאים.</p>\n' +
|
||||
'\n' +
|
||||
'<p>מקשי החיצים <strong>שמאלה</strong> ו<strong>ימינה</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>עבור בין תפריטים בשורת התפריטים.</li>\n' +
|
||||
' <li>פתח תפריט משני בתפריט.</li>\n' +
|
||||
' <li>עבור בין לחצנים בקבוצה בסרגל הכלים.</li>\n' +
|
||||
' <li>עבור בין פריטים ברכיב בכותרת התחתונה.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>מקשי החיצים <strong>למטה</strong> ו<strong>למעלה</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>עבור בין פריטים בתפריט.</li>\n' +
|
||||
' <li>עבור בין פריטים בחלון הקובץ של סרגל הכלים.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>מקשי <strong>החצים</strong> משתנים בתוך המקטע במסך שעליו נמצא המיקוד.</p>\n' +
|
||||
'\n' +
|
||||
'<p>כדי לסגור תפריט פתוח, תפריט משני פתוח או חלון קופץ, הקש על <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
"<p>אם המיקוד הוא על החלק 'העליון' של מקטע מסוים במסך, הקשה על <strong>Esc</strong> מביאה גם ליציאה\n" +
|
||||
' מהניווט במקלדת לחלוטין.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>הפעל פריט בתפריט או לחצן בסרגל הכלים</h1>\n' +
|
||||
'\n' +
|
||||
'<p>כאשר הפריט הרצוי בתפריט או הלחצן בסרגל הכלים מודגשים, הקש על <strong>Return</strong>, <strong>Enter</strong>,\n' +
|
||||
' או על <strong>מקש הרווח</strong> כדי להפעיל את הפריט.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>ניווט בחלונות דו-שיח בלי כרטיסיות</h1>\n' +
|
||||
'\n' +
|
||||
'<p>בחלונות דו-שיח בלי כרטיסיות, הרכיב האינטראקטיבי הראשון מקבל את המיקוד כאשר החלון נפתח.</p>\n' +
|
||||
'\n' +
|
||||
'<p>עבור בין רכיבים אינטראקטיביים בחלון על ידי הקשה על <strong>Tab</strong> או <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>ניווט בחלונות דו-שיח עם כרטיסיות</h1>\n' +
|
||||
'\n' +
|
||||
'<p>בחלונות דו-שיח עם כרטיסיות, הלחצן הראשון בתפריט מקבל את המיקוד כאשר החלון נפתח.</p>\n' +
|
||||
'\n' +
|
||||
'<p>עבור בין רכיבים אינטראקטיביים בחלון על ידי הקשה על <strong>Tab</strong> או\n' +
|
||||
' <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>עבור לכרטיסיה אחרת בחלון על ידי העברת המיקוד לתפריט הכרטיסיות והקשה על <strong>החץ</strong>המתאים\n' +
|
||||
' כדי לעבור בין הכרטיסיות הזמינות.</p>\n');
|
||||
@@ -0,0 +1,93 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.hu-HU',
|
||||
'<h1>Billentyűzetes navigáció indítása</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Fókusz a menüsávra</dt>\n' +
|
||||
' <dd>Windows és Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Fókusz az eszköztárra</dt>\n' +
|
||||
' <dd>Windows és Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Fókusz a láblécre</dt>\n' +
|
||||
' <dd>Windows és Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Ráközelítés az értesítésre</dt>\n' +
|
||||
' <dd>Windows vagy Linux: Alt+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>Fókusz egy környezetfüggő eszköztárra</dt>\n' +
|
||||
' <dd>Windows, Linux és macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>A navigáció az első felhasználói felületi elemnél kezdődik, amelyet a rendszer kiemel, illetve aláhúz, amennyiben az az első elem\n' +
|
||||
' a lábléc elemútvonalán.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigálás a felhasználói felület szakaszai között</h1>\n' +
|
||||
'\n' +
|
||||
'<p>A felhasználói felület következő szakaszára váltáshoz nyomja meg a <strong>Tab</strong> billentyűt.</p>\n' +
|
||||
'\n' +
|
||||
'<p>A felhasználói felület előző szakaszára váltáshoz nyomja meg a <strong>Shift+Tab</strong> billentyűt.</p>\n' +
|
||||
'\n' +
|
||||
'<p>A <strong>Tab</strong> billentyűvel a felhasználói felület szakaszai között a következő sorrendben vált:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Menüsáv</li>\n' +
|
||||
' <li>Az egyes eszköztárcsoportok</li>\n' +
|
||||
' <li>Oldalsáv</li>\n' +
|
||||
' <li>Elemútvonal a láblécen</li>\n' +
|
||||
' <li>Szószámátkapcsoló gomb a láblécen</li>\n' +
|
||||
' <li>Márkalink a láblécen</li>\n' +
|
||||
' <li>Szerkesztő átméretezési fogópontja a láblécen</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Ha a felhasználói felület valamelyik eleme nincs jelen, a rendszer kihagyja.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Ha a billentyűzetes navigáció fókusza a láblécen van, és nincs látható oldalsáv, a <strong>Shift+Tab</strong>\n' +
|
||||
' billentyűkombináció lenyomásakor az első eszköztárcsoportra ugrik a fókusz, nem az utolsóra.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigálás a felhasználói felület szakaszain belül</h1>\n' +
|
||||
'\n' +
|
||||
'<p>A felhasználói felület következő elemére váltáshoz nyomja meg a megfelelő <strong>nyílbillentyűt</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>A <strong>bal</strong> és a <strong>jobb</strong> nyílgomb</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>a menüsávban a menük között vált.</li>\n' +
|
||||
' <li>a menükben megnyit egy almenüt.</li>\n' +
|
||||
' <li>az eszköztárcsoportban a gombok között vált.</li>\n' +
|
||||
' <li>a lábléc elemútvonalán az elemek között vált.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>A <strong>le</strong> és a <strong>fel</strong> nyílgomb</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>a menükben a menüpontok között vált.</li>\n' +
|
||||
' <li>az eszköztár előugró menüjében az elemek között vált.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>A <strong>nyílbillentyűk</strong> lenyomásával körkörösen lépkedhet a fókuszban lévő felhasználói felületi szakasz elemei között.</p>\n' +
|
||||
'\n' +
|
||||
'<p>A megnyitott menüket, almenüket és előugró menüket az <strong>Esc</strong> billentyűvel zárhatja be.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Ha a fókusz az aktuális felületi elem „felső” részén van, az <strong>Esc</strong> billentyűvel az egész\n' +
|
||||
' billentyűzetes navigációból kilép.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Menüpont vagy eszköztárgomb aktiválása</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Amikor a kívánt menüelem vagy eszköztárgomb van kijelölve, nyomja meg a <strong>Return</strong>, az <strong>Enter</strong>\n' +
|
||||
' vagy a <strong>Szóköz</strong> billentyűt az adott elem vagy gomb aktiválásához.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigálás a lapokkal nem rendelkező párbeszédablakokban</h1>\n' +
|
||||
'\n' +
|
||||
'<p>A lapokkal nem rendelkező párbeszédablakokban az első interaktív összetevő kapja a fókuszt, amikor a párbeszédpanel megnyílik.</p>\n' +
|
||||
'\n' +
|
||||
'<p>A párbeszédpanelek interaktív összetevői között a <strong>Tab</strong> vagy a <strong>Shift+Tab</strong> billentyűvel navigálhat.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigálás a lapokkal rendelkező párbeszédablakokban</h1>\n' +
|
||||
'\n' +
|
||||
'<p>A lapokkal rendelkező párbeszédablakokban a lapmenü első gombja kapja a fókuszt, amikor a párbeszédpanel megnyílik.</p>\n' +
|
||||
'\n' +
|
||||
'<p>A párbeszédpanel e lapjának interaktív összetevői között a <strong>Tab</strong> vagy\n' +
|
||||
' <strong>Shift+Tab</strong> billentyűvel navigálhat.</p>\n' +
|
||||
'\n' +
|
||||
'<p>A párbeszédablak másik lapjára úgy léphet, hogy a fókuszt a lapmenüre állítja, majd lenyomja a megfelelő <strong>nyílbillentyűt</strong>\n' +
|
||||
' a rendelkezésre álló lapok közötti lépkedéshez.</p>\n');
|
||||
@@ -0,0 +1,93 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.ko-KR',
|
||||
'<h1>키보드 탐색 시작</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>메뉴 모음 포커스 표시</dt>\n' +
|
||||
' <dd>Windows 또는 Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>도구 모음 포커스 표시</dt>\n' +
|
||||
' <dd>Windows 또는 Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>푸터 포커스 표시</dt>\n' +
|
||||
' <dd>Windows 또는 Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>알림 포커스</dt>\n' +
|
||||
' <dd>Windows 또는 Linux: Alt+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>컨텍스트 도구 모음에 포커스 표시</dt>\n' +
|
||||
' <dd>Windows, Linux 또는 macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>첫 번째 UI 항목에서 탐색이 시작되며, 이때 첫 번째 항목이 강조 표시되거나 푸터 요소 경로에 있는\n' +
|
||||
' 경우 밑줄 표시됩니다.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>UI 섹션 간 탐색</h1>\n' +
|
||||
'\n' +
|
||||
'<p>한 UI 섹션에서 다음 UI 섹션으로 이동하려면 <strong>Tab(탭)</strong>을 누릅니다.</p>\n' +
|
||||
'\n' +
|
||||
'<p>한 UI 섹션에서 이전 UI 섹션으로 돌아가려면 <strong>Shift+Tab(시프트+탭)</strong>을 누릅니다.</p>\n' +
|
||||
'\n' +
|
||||
'<p>이 UI 섹션의 <strong>Tab(탭)</strong> 순서는 다음과 같습니다.</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>메뉴 바</li>\n' +
|
||||
' <li>각 도구 모음 그룹</li>\n' +
|
||||
' <li>사이드바</li>\n' +
|
||||
' <li>푸터의 요소 경로</li>\n' +
|
||||
' <li>푸터의 단어 수 토글 버튼</li>\n' +
|
||||
' <li>푸터의 브랜딩 링크</li>\n' +
|
||||
' <li>푸터의 에디터 크기 변경 핸들</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>UI 섹션이 없는 경우 건너뛰기합니다.</p>\n' +
|
||||
'\n' +
|
||||
'<p>푸터에 키보드 탐색 포커스가 있고 사이드바는 보이지 않는 경우 <strong>Shift+Tab(시프트+탭)</strong>을 누르면\n' +
|
||||
' 포커스 표시가 마지막이 아닌 첫 번째 도구 모음 그룹으로 이동합니다.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>UI 섹션 내 탐색</h1>\n' +
|
||||
'\n' +
|
||||
'<p>한 UI 요소에서 다음 UI 요소로 이동하려면 적절한 <strong>화살표</strong> 키를 누릅니다.</p>\n' +
|
||||
'\n' +
|
||||
'<p><strong>왼쪽</strong>과 <strong>오른쪽</strong> 화살표 키의 용도:</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>메뉴 모음에서 메뉴 항목 사이를 이동합니다.</li>\n' +
|
||||
' <li>메뉴에서 하위 메뉴를 엽니다.</li>\n' +
|
||||
' <li>도구 모음 그룹에서 버튼 사이를 이동합니다.</li>\n' +
|
||||
' <li>푸터의 요소 경로에서 항목 간에 이동합니다.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>아래</strong>와 <strong>위</strong> 화살표 키의 용도:</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>메뉴에서 메뉴 항목 사이를 이동합니다.</li>\n' +
|
||||
' <li>도구 모음 팝업 메뉴에서 메뉴 항목 사이를 이동합니다.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>화살표</strong> 키는 포커스 표시 UI 섹션 내에서 순환됩니다.</p>\n' +
|
||||
'\n' +
|
||||
'<p>열려 있는 메뉴, 열려 있는 하위 메뉴 또는 열려 있는 팝업 메뉴를 닫으려면 <strong>Esc</strong> 키를 누릅니다.</p>\n' +
|
||||
'\n' +
|
||||
"<p>현재 포커스 표시가 특정 UI 섹션 '상단'에 있는 경우 이때도 <strong>Esc</strong> 키를 누르면\n" +
|
||||
' 키보드 탐색이 완전히 종료됩니다.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>메뉴 항목 또는 도구 모음 버튼 실행</h1>\n' +
|
||||
'\n' +
|
||||
'<p>원하는 메뉴 항목 또는 도구 모음 버튼이 강조 표시되어 있을 때 <strong>Return(리턴)</strong>, <strong>Enter(엔터)</strong>,\n' +
|
||||
' 또는 <strong>Space bar(스페이스바)</strong>를 눌러 해당 항목을 실행합니다.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>탭이 없는 대화 탐색</h1>\n' +
|
||||
'\n' +
|
||||
'<p>탭이 없는 대화의 경우, 첫 번째 대화형 요소가 포커스 표시된 상태로 대화가 열립니다.</p>\n' +
|
||||
'\n' +
|
||||
'<p>대화형 요소들 사이를 이동할 때는 <strong>Tab(탭)</strong> 또는 <strong>Shift+Tab(시프트+탭)</strong>을 누릅니다.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>탭이 있는 대화 탐색</h1>\n' +
|
||||
'\n' +
|
||||
'<p>탭이 있는 대화의 경우, 탭 메뉴에서 첫 번째 버튼이 포커스 표시된 상태로 대화가 열립니다.</p>\n' +
|
||||
'\n' +
|
||||
'<p>이 대화 탭의 대화형 요소들 사이를 이동할 때는 <strong>Tab(탭)</strong> 또는\n' +
|
||||
' <strong>Shift+Tab(시프트+탭)</strong>을 누릅니다.</p>\n' +
|
||||
'\n' +
|
||||
'<p>다른 대화 탭으로 이동하려면 탭 메뉴를 포커스 표시한 다음 적절한 <strong>화살표</strong>\n' +
|
||||
' 키를 눌러 사용 가능한 탭들을 지나 원하는 탭으로 이동합니다.</p>\n');
|
||||
@@ -0,0 +1,93 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.nb-NO',
|
||||
'<h1>Starte tastaturnavigering</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Utheve menylinjen</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt + F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Utheve verktøylinjen</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt + F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Utheve bunnteksten</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt + F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Fokuser på varselet</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>Utheve en kontekstuell verktøylinje</dt>\n' +
|
||||
' <dd>Windows, Linux eller macOS: Ctrl + F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Navigeringen starter ved det første grensesnittelementet, som utheves, eller understrekes når det gjelder det første elementet i\n' +
|
||||
' elementstien i bunnteksten.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigere mellom grensesnittdeler</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Du kan bevege deg fra én grensesnittdel til den neste ved å trykke på <strong>tabulatortasten</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Du kan bevege deg fra én grensesnittdel til den forrige ved å trykke på <strong>Shift + tabulatortasten</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Rekkefølgen til <strong>tabulatortasten</strong> gjennom grensesnittdelene er:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Menylinjen</li>\n' +
|
||||
' <li>Hver gruppe på verktøylinjen</li>\n' +
|
||||
' <li>Sidestolpen</li>\n' +
|
||||
' <li>Elementstien i bunnteksten</li>\n' +
|
||||
' <li>Veksleknappen for ordantall i bunnteksten</li>\n' +
|
||||
' <li>Merkelenken i bunnteksten</li>\n' +
|
||||
' <li>Skaleringshåndtaket for redigeringsprogrammet i bunnteksten</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Hvis en grensesnittdel ikke er til stede, blir den hoppet over.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Hvis tastaturnavigeringen har uthevet bunnteksten og det ikke finnes en synlig sidestolpe, kan du trykke på <strong>Shift + tabulatortasten</strong>\n' +
|
||||
' for å flytte fokuset til den første gruppen på verktøylinjen i stedet for den siste.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigere innenfor grensesnittdeler</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Du kan bevege deg fra ett grensesnittelement til det neste ved å trykke på den aktuelle <strong>piltasten</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>De <strong>venstre</strong> og <strong>høyre</strong> piltastene</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>beveger deg mellom menyer på menylinjen.</li>\n' +
|
||||
' <li>åpner en undermeny i en meny.</li>\n' +
|
||||
' <li>beveger deg mellom knapper i en gruppe på verktøylinjen.</li>\n' +
|
||||
' <li>beveger deg mellom elementer i elementstien i bunnteksten.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Ned</strong>- og <strong>opp</strong>-piltastene</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>beveger deg mellom menyelementer i en meny.</li>\n' +
|
||||
' <li>beveger deg mellom elementer i en hurtigmeny på verktøylinjen.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>Med <strong>piltastene</strong> kan du bevege deg innenfor den uthevede grensesnittdelen.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Du kan lukke en åpen meny, en åpen undermeny eller en åpen hurtigmeny ved å klikke på <strong>Esc</strong>-tasten.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Hvis det øverste nivået i en grensesnittdel er uthevet, kan du ved å trykke på <strong>Esc</strong> også avslutte\n' +
|
||||
' tastaturnavigeringen helt.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Utføre et menyelement eller en knapp på en verktøylinje</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Når det ønskede menyelementet eller verktøylinjeknappen er uthevet, trykker du på <strong>Retur</strong>, <strong>Enter</strong>,\n' +
|
||||
' eller <strong>mellomromstasten</strong> for å utføre elementet.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigere i dialogbokser uten faner</h1>\n' +
|
||||
'\n' +
|
||||
'<p>I dialogbokser uten faner blir den første interaktive komponenten uthevet når dialogboksen åpnes.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Naviger mellom interaktive komponenter i dialogboksen ved å trykke på <strong>tabulatortasten</strong> eller <strong>Shift + tabulatortasten</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigere i fanebaserte dialogbokser</h1>\n' +
|
||||
'\n' +
|
||||
'<p>I fanebaserte dialogbokser blir den første knappen i fanemenyen uthevet når dialogboksen åpnes.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Naviger mellom interaktive komponenter i fanen ved å trykke på <strong>tabulatortasten</strong> eller\n' +
|
||||
' <strong>Shift + tabulatortasten</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Veksle til en annen fane i dialogboksen ved å utheve fanemenyen, og trykk deretter på den aktuelle <strong>piltasten</strong>\n' +
|
||||
' for å bevege deg mellom de tilgjengelige fanene.</p>\n');
|
||||
@@ -0,0 +1,93 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.pt-BR',
|
||||
'<h1>Iniciar navegação pelo teclado</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Foco na barra de menus</dt>\n' +
|
||||
' <dd>Windows ou Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Foco na barra de ferramentas</dt>\n' +
|
||||
' <dd>Windows ou Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Foco no rodapé</dt>\n' +
|
||||
' <dd>Windows ou Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Foco na notificação</dt>\n' +
|
||||
' <dd>Windows ou Linux: Alt+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>Foco na barra de ferramentas contextual</dt>\n' +
|
||||
' <dd>Windows, Linux ou macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>A navegação inicia no primeiro item da IU, que será destacado ou sublinhado no caso do primeiro item no\n' +
|
||||
' caminho do elemento Rodapé.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navegar entre seções da IU</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Para ir de uma seção da IU para a seguinte, pressione <strong>Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Para ir de uma seção da IU para a anterior, pressione <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>A ordem de <strong>Tab</strong> destas seções da IU é:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Barra de menus</li>\n' +
|
||||
' <li>Cada grupo da barra de ferramentas</li>\n' +
|
||||
' <li>Barra lateral</li>\n' +
|
||||
' <li>Caminho do elemento no rodapé</li>\n' +
|
||||
' <li>Botão de alternar contagem de palavras no rodapé</li>\n' +
|
||||
' <li>Link da marca no rodapé</li>\n' +
|
||||
' <li>Alça de redimensionamento do editor no rodapé</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Se não houver uma seção da IU, ela será pulada.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Se o rodapé tiver o foco da navegação pelo teclado e não houver uma barra lateral visível, pressionar <strong>Shift+Tab</strong>\n' +
|
||||
' move o foco para o primeiro grupo da barra de ferramentas, não para o último.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navegar dentro das seções da IU</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Para ir de um elemento da IU para o seguinte, pressione a <strong>Seta</strong> correspondente.</p>\n' +
|
||||
'\n' +
|
||||
'<p>As teclas de seta <strong>Esquerda</strong> e <strong>Direita</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>movem entre menus na barra de menus.</li>\n' +
|
||||
' <li>abrem um submenu em um menu.</li>\n' +
|
||||
' <li>movem entre botões em um grupo da barra de ferramentas.</li>\n' +
|
||||
' <li>movem entre itens no caminho do elemento do rodapé.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>As teclas de seta <strong>Abaixo</strong> e <strong>Acima</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>movem entre itens de menu em um menu.</li>\n' +
|
||||
' <li>movem entre itens em um menu suspenso da barra de ferramentas.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>As teclas de <strong>Seta</strong> alternam dentre a seção da IU em foco.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Para fechar um menu aberto, um submenu aberto ou um menu suspenso aberto, pressione <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Se o foco atual estiver no ‘alto’ de determinada seção da IU, pressionar <strong>Esc</strong> também sai\n' +
|
||||
' totalmente da navegação pelo teclado.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Executar um item de menu ou botão da barra de ferramentas</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Com o item de menu ou botão da barra de ferramentas desejado destacado, pressione <strong>Return</strong>, <strong>Enter</strong>,\n' +
|
||||
' ou a <strong>Barra de espaço</strong> para executar o item.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navegar por caixas de diálogo sem guias</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Em caixas de diálogo sem guias, o primeiro componente interativo recebe o foco quando a caixa de diálogo abre.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Navegue entre componentes interativos de caixa de diálogo pressionando <strong>Tab</strong> ou <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navegar por caixas de diálogo com guias</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Em caixas de diálogo com guias, o primeiro botão no menu da guia recebe o foco quando a caixa de diálogo abre.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Navegue entre componentes interativos dessa guia da caixa de diálogo pressionando <strong>Tab</strong> ou\n' +
|
||||
' <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Alterne para outra guia da caixa de diálogo colocando o foco no menu da guia e pressionando a <strong>Seta</strong>\n' +
|
||||
' adequada para percorrer as guias disponíveis.</p>\n');
|
||||
@@ -0,0 +1,93 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.pt-PT',
|
||||
'<h1>Iniciar navegação com teclado</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Foco na barra de menu</dt>\n' +
|
||||
' <dd>Windows ou Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Foco na barra de ferramentas</dt>\n' +
|
||||
' <dd>Windows ou Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Foco no rodapé</dt>\n' +
|
||||
' <dd>Windows ou Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Focar a notificação</dt>\n' +
|
||||
' <dd>Windows ou Linux: Alt+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>Foco numa barra de ferramentas contextual</dt>\n' +
|
||||
' <dd>Windows, Linux ou macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>A navegação começará no primeiro item de IU, que estará realçado ou sublinhado, no caso do primeiro item no\n' +
|
||||
' caminho do elemento do rodapé.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navegar entre secções de IU</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Para se mover de uma secção de IU para a seguinte, prima <strong>Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Para se mover de uma secção de IU para a anterior, prima <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>A ordem de <strong>tabulação</strong> destas secções de IU é:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Barra de menu</li>\n' +
|
||||
' <li>Cada grupo da barra de ferramentas</li>\n' +
|
||||
' <li>Barra lateral</li>\n' +
|
||||
' <li>Caminho do elemento no rodapé</li>\n' +
|
||||
' <li>Botão de alternar da contagem de palavras no rodapé</li>\n' +
|
||||
' <li>Ligação da marca no rodapé</li>\n' +
|
||||
' <li>Alça de redimensionamento do editor no rodapé</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Se uma secção de IU não estiver presente, é ignorada.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Se o rodapé tiver foco de navegação com teclado e não existir uma barra lateral visível, premir <strong>Shift+Tab</strong>\n' +
|
||||
' move o foco para o primeiro grupo da barra de ferramentas e não para o último.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navegar nas secções de IU</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Para se mover de um elemento de IU para o seguinte, prima a tecla de <strong>seta</strong> adequada.</p>\n' +
|
||||
'\n' +
|
||||
'<p>As teclas de seta <strong>Para a esquerda</strong> e <strong>Para a direita</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>movem-se entre menus na barra de menu.</li>\n' +
|
||||
' <li>abrem um submenu num menu.</li>\n' +
|
||||
' <li>movem-se entre botões num grupo da barra de ferramentas.</li>\n' +
|
||||
' <li>movem-se entre itens no caminho do elemento do rodapé.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>As teclas de seta <strong>Para cima</strong> e <strong>Para baixo</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>movem-se entre itens de menu num menu.</li>\n' +
|
||||
' <li>movem-se entre itens num menu de pop-up da barra de ferramentas.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>As teclas de <strong>seta</strong> deslocam-se ciclicamente na secção de IU em foco.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Para fechar um menu aberto, um submenu aberto ou um menu de pop-up aberto, prima a tecla <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Se o foco atual estiver no "topo" de determinada secção de IU, premir a tecla <strong>Esc</strong> também fecha\n' +
|
||||
' completamente a navegação com teclado.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Executar um item de menu ou botão da barra de ferramentas</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Quando o item de menu ou o botão da barra de ferramentas pretendido estiver realçado, prima <strong>Retrocesso</strong>, <strong>Enter</strong>\n' +
|
||||
' ou a <strong>Barra de espaço</strong> para executar o item.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navegar em diálogos sem separadores</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Nos diálogos sem separadores, o primeiro componente interativo fica em foco quando o diálogo abre.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Navegue entre componentes interativos do diálogo, premindo <strong>Tab</strong> ou <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navegar em diálogos com separadores</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Nos diálogos com separadores, o primeiro botão no menu do separador fica em foco quando o diálogo abre.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Navegue entre os componentes interativos deste separador do diálogo, premindo <strong>Tab</strong> ou\n' +
|
||||
' <strong>Shift+Tab</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Mude para outro separador do diálogo colocando o menu do separador em foco e, em seguida, premindo a tecla de <strong>seta</strong>\n' +
|
||||
' adequada para se deslocar ciclicamente pelos separadores disponíveis.</p>\n');
|
||||
@@ -0,0 +1,93 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.sl-SI',
|
||||
'<h1>Začetek krmarjenja s tipkovnico</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Fokus na menijsko vrstico</dt>\n' +
|
||||
' <dd>Windows ali Linux: Alt + F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Fokus na orodno vrstico</dt>\n' +
|
||||
' <dd>Windows ali Linux: Alt + F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Fokus na nogo</dt>\n' +
|
||||
' <dd>Windows ali Linux: Alt + F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Označitev obvestila</dt>\n' +
|
||||
' <dd>Windows ali Linux: Alt + F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>Fokus na kontekstualno orodno vrstico</dt>\n' +
|
||||
' <dd>Windows, Linux ali macOS: Ctrl + F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Krmarjenje se bo začelo s prvim elementom uporabniškega vmesnika, ki bo izpostavljena ali podčrtan, če gre za prvi element na\n' +
|
||||
' poti do elementa noge.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Krmarjenje med razdelki uporabniškega vmesnika</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Če se želite pomakniti z enega dela uporabniškega vmesnika na naslednjega, pritisnite <strong>tabulatorko</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Če se želite pomakniti z enega dela uporabniškega vmesnika na prejšnjega, pritisnite <strong>shift + tabulatorko</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Zaporedje teh razdelkov uporabniškega vmesnika, ko pritiskate <strong>tabulatorko</strong>, je:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Menijska vrstica</li>\n' +
|
||||
' <li>Posamezne skupine orodne vrstice</li>\n' +
|
||||
' <li>Stranska vrstica</li>\n' +
|
||||
' <li>Pod do elementa v nogi</li>\n' +
|
||||
' <li>Gumb za preklop štetja besed v nogi</li>\n' +
|
||||
' <li>Povezava do blagovne znamke v nogi</li>\n' +
|
||||
' <li>Ročaj za spreminjanje velikosti urejevalnika v nogi</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Če razdelek uporabniškega vmesnika ni prisoten, je preskočen.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Če ima noga fokus za krmarjenje s tipkovnico in ni vidne stranske vrstice, s pritiskom na <strong>shift + tabulatorko</strong>\n' +
|
||||
' fokus premaknete na prvo skupino orodne vrstice, ne zadnjo.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Krmarjenje v razdelkih uporabniškega vmesnika</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Če se želite premakniti z enega elementa uporabniškega vmesnika na naslednjega, pritisnite ustrezno <strong>puščično</strong> tipko.</p>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Leva</strong> in <strong>desna</strong> puščična tipka</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>omogočata premikanje med meniji v menijski vrstici.</li>\n' +
|
||||
' <li>odpreta podmeni v meniju.</li>\n' +
|
||||
' <li>omogočata premikanje med gumbi v skupini orodne vrstice.</li>\n' +
|
||||
' <li>omogočata premikanje med elementi na poti do elementov noge.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Spodnja</strong> in <strong>zgornja</strong> puščična tipka</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>omogočata premikanje med elementi menija.</li>\n' +
|
||||
' <li>omogočata premikanje med elementi v pojavnem meniju orodne vrstice.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Puščične</strong> tipke omogočajo kroženje znotraj razdelka uporabniškega vmesnika, na katerem je fokus.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Če želite zapreti odprt meni, podmeni ali pojavni meni, pritisnite tipko <strong>Esc</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Če je trenutni fokus na »vrhu« določenega razdelka uporabniškega vmesnika, s pritiskom tipke <strong>Esc</strong> zaprete\n' +
|
||||
' tudi celotno krmarjenje s tipkovnico.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Izvajanje menijskega elementa ali gumba orodne vrstice</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Ko je označen želeni menijski element ali orodja vrstica, pritisnite <strong>vračalko</strong>, <strong>Enter</strong>\n' +
|
||||
' ali <strong>preslednico</strong>, da izvedete element.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Krmarjenje po pogovornih oknih brez zavihkov</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Ko odprete pogovorno okno brez zavihkov, ima fokus prva interaktivna komponenta.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Med interaktivnimi komponentami pogovornega okna se premikate s pritiskom <strong>tabulatorke</strong> ali kombinacije tipke <strong>shift + tabulatorke</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Krmarjenje po pogovornih oknih z zavihki</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Ko odprete pogovorno okno z zavihki, ima fokus prvi gumb v meniju zavihka.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Med interaktivnimi komponentami tega zavihka pogovornega okna se premikate s pritiskom <strong>tabulatorke</strong> ali\n' +
|
||||
' kombinacije tipke <strong>shift + tabulatorke</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Na drug zavihek pogovornega okna preklopite tako, da fokus prestavite na meni zavihka in nato pritisnete ustrezno <strong>puščično</strong>\n' +
|
||||
' tipko, da se pomaknete med razpoložljivimi zavihki.</p>\n');
|
||||
@@ -0,0 +1,93 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.sv-SE',
|
||||
'<h1>Påbörja tangentbordsnavigering</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>Fokusera på menyraden</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>Fokusera på verktygsraden</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>Fokusera på verktygsraden</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>Fokusera aviseringen</dt>\n' +
|
||||
' <dd>Windows eller Linux: Alt+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>Fokusera på en snabbverktygsrad</dt>\n' +
|
||||
' <dd>Windows, Linux eller macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>Navigeringen börjar vid det första gränssnittsobjektet, vilket är markerat eller understruket om det gäller det första objektet i\n' +
|
||||
' sidfotens elementsökväg.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigera mellan UI-avsnitt</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Flytta från ett UI-avsnitt till nästa genom att trycka på <strong>Tabb</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Flytta från ett UI-avsnitt till det föregående genom att trycka på <strong>Skift+Tabb</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Tabb</strong>-ordningen för dessa UI-avsnitt är:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>Menyrad</li>\n' +
|
||||
' <li>Varje verktygsradsgrupp</li>\n' +
|
||||
' <li>Sidoruta</li>\n' +
|
||||
' <li>Elementsökväg i sidfoten</li>\n' +
|
||||
' <li>Växlingsknapp för ordantal i sidfoten</li>\n' +
|
||||
' <li>Varumärkeslänk i sidfoten</li>\n' +
|
||||
' <li>Storlekshandtag för redigeraren i sidfoten</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>Om ett UI-avsnitt inte finns hoppas det över.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Om sidfoten har fokus på tangentbordsnavigering, och det inte finns någon synlig sidoruta, flyttas fokus till den första verktygsradsgruppen\n' +
|
||||
' när du trycker på <strong>Skift+Tabb</strong>, inte till den sista.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigera i UI-avsnitt</h1>\n' +
|
||||
'\n' +
|
||||
'<p>Flytta från ett UI-element till nästa genom att trycka på motsvarande <strong>piltangent</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Vänsterpil</strong> och <strong>högerpil</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>flytta mellan menyer på menyraden.</li>\n' +
|
||||
' <li>öppna en undermeny på en meny.</li>\n' +
|
||||
' <li>flytta mellan knappar i en verktygsradgrupp.</li>\n' +
|
||||
' <li>flytta mellan objekt i sidfotens elementsökväg.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Nedpil</strong> och <strong>uppil</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>flytta mellan menyalternativ på en meny.</li>\n' +
|
||||
' <li>flytta mellan alternativ på en popup-meny på verktygsraden.</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>Piltangenterna</strong> cirkulerar inom det fokuserade UI-avsnittet.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Tryck på <strong>Esc</strong>-tangenten om du vill stänga en öppen meny, undermeny eller popup-meny.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Om det aktuella fokuset är högst upp i ett UI-avsnitt avlutas även tangentbordsnavigeringen helt när\n' +
|
||||
' du trycker på <strong>Esc</strong>-tangenten.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Köra ett menyalternativ eller en verktygfältsknapp</h1>\n' +
|
||||
'\n' +
|
||||
'<p>När menyalternativet eller verktygsradsknappen är markerad trycker du på <strong>Retur</strong>, <strong>Enter</strong>\n' +
|
||||
' eller <strong>blanksteg</strong> för att köra alternativet.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigera i dialogrutor utan flikar</h1>\n' +
|
||||
'\n' +
|
||||
'<p>I dialogrutor utan flikar är den första interaktiva komponenten i fokus när dialogrutan öppnas.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Navigera mellan interaktiva dialogkomponenter genom att trycka på <strong>Tabb</strong> eller <strong>Skift+Tabb</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<h1>Navigera i dialogrutor med flikar</h1>\n' +
|
||||
'\n' +
|
||||
'<p>I dialogrutor utan flikar är den första knappen på flikmenyn i fokus när dialogrutan öppnas.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Navigera mellan interaktiva komponenter på dialogrutefliken genom att trycka på <strong>Tabb</strong> eller\n' +
|
||||
' <strong>Skift+Tabb</strong>.</p>\n' +
|
||||
'\n' +
|
||||
'<p>Växla till en annan dialogruta genom att fokusera på flikmenyn och sedan trycka på motsvarande <strong>piltangent</strong>\n' +
|
||||
' för att cirkulera mellan de tillgängliga flikarna.</p>\n');
|
||||
@@ -0,0 +1,93 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.th-TH',
|
||||
'<h1>เริ่มต้นการนำทางด้วยแป้นพิมพ์</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>โฟกัสที่แถบเมนู</dt>\n' +
|
||||
' <dd>Windows หรือ Linux: Alt+F9</dd>\n' +
|
||||
' <dd>macOS: ⌥F9</dd>\n' +
|
||||
' <dt>โฟกัสที่แถบเครื่องมือ</dt>\n' +
|
||||
' <dd>Windows หรือ Linux: Alt+F10</dd>\n' +
|
||||
' <dd>macOS: ⌥F10</dd>\n' +
|
||||
' <dt>โฟกัสที่ส่วนท้าย</dt>\n' +
|
||||
' <dd>Windows หรือ Linux: Alt+F11</dd>\n' +
|
||||
' <dd>macOS: ⌥F11</dd>\n' +
|
||||
' <dt>โฟกัสไปที่การแจ้งเตือน</dt>\n' +
|
||||
' <dd>Windows หรือ Linux: Alt+F12</dd>\n' +
|
||||
' <dd>macOS: ⌥F12</dd>\n' +
|
||||
' <dt>โฟกัสที่แถบเครื่องมือตามบริบท</dt>\n' +
|
||||
' <dd>Windows, Linux หรือ macOS: Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>การนำทางจะเริ่มที่รายการ UI แรก ซึ่งจะมีการไฮไลต์หรือขีดเส้นใต้ไว้ในกรณีที่รายการแรกอยู่ใน\n' +
|
||||
' พาธองค์ประกอบส่วนท้าย</p>\n' +
|
||||
'\n' +
|
||||
'<h1>การนำทางระหว่างส่วนต่างๆ ของ UI</h1>\n' +
|
||||
'\n' +
|
||||
'<p>ในการย้ายจากส่วน UI หนึ่งไปยังส่วนถัดไป ให้กด <strong>Tab</strong></p>\n' +
|
||||
'\n' +
|
||||
'<p>ในการย้ายจากส่วน UI หนึ่งไปยังส่วนก่อนหน้า ให้กด <strong>Shift+Tab</strong></p>\n' +
|
||||
'\n' +
|
||||
'<p>ลำดับ<strong>แท็บ</strong>ของส่วนต่างๆ ของ UI คือ:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>แถบเมนู</li>\n' +
|
||||
' <li>แต่ละกลุ่มแถบเครื่องมือ</li>\n' +
|
||||
' <li>แถบข้าง</li>\n' +
|
||||
' <li>พาธองค์ประกอบในส่วนท้าย</li>\n' +
|
||||
' <li>ปุ่มสลับเปิด/ปิดจำนวนคำในส่วนท้าย</li>\n' +
|
||||
' <li>ลิงก์ชื่อแบรนด์ในส่วนท้าย</li>\n' +
|
||||
' <li>จุดจับปรับขนาดของตัวแก้ไขในส่วนท้าย</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>หากส่วน UI ไม่ปรากฏ แสดงว่าถูกข้ามไป</p>\n' +
|
||||
'\n' +
|
||||
'<p>หากส่วนท้ายมีการโฟกัสการนำทางแป้นพิมพ์และไม่มีแถบข้างปรากฏ การกด <strong>Shift+Tab</strong>\n' +
|
||||
' จะย้ายการโฟกัสไปที่กลุ่มแถบเครื่องมือแรก ไม่ใช่สุดท้าย</p>\n' +
|
||||
'\n' +
|
||||
'<h1>การนำทางภายในส่วนต่างๆ ของ UI</h1>\n' +
|
||||
'\n' +
|
||||
'<p>ในการย้ายจากองค์ประกอบ UI หนึ่งไปยังองค์ประกอบส่วนถัดไป ให้กดปุ่ม<strong>ลูกศร</strong>ที่เหมาะสม</p>\n' +
|
||||
'\n' +
|
||||
'<p>ปุ่มลูกศร<strong>ซ้าย</strong>และ<strong>ขวา</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>ย้ายไปมาระหว่างเมนูต่างๆ ในแถบเมนู</li>\n' +
|
||||
' <li>เปิดเมนูย่อยในเมนู</li>\n' +
|
||||
' <li>ย้ายไปมาระหว่างปุ่มต่างๆ ในกลุ่มแถบเครื่องมือ</li>\n' +
|
||||
' <li>ย้ายไปมาระหว่างรายการต่างๆ ในพาธองค์ประกอบของส่วนท้าย</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>ปุ่มลูกศร<strong>ลง</strong>และ<strong>ขึ้น</strong></p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>ย้ายไปมาระหว่างรายการเมนูต่างๆ ในเมนู</li>\n' +
|
||||
' <li>ย้ายไปมาระหว่างรายการต่างๆ ในเมนูป๊อบอัพแถบเครื่องมือ</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p>ปุ่ม<strong>ลูกศร</strong>จะเลื่อนไปมาภายในส่วน UI ที่โฟกัส</p>\n' +
|
||||
'\n' +
|
||||
'<p>ในการปิดเมนูที่เปิดอยู่ เมนูย่อยที่เปิดอยู่ หรือเมนูป๊อบอัพที่เปิดอยู่ ให้กดปุ่ม <strong>Esc</strong></p>\n' +
|
||||
'\n' +
|
||||
'<p>หากโฟกัสปัจจุบันอยู่ที่ ‘ด้านบนสุด’ ของส่วน UI เฉพาะ การกดปุ่ม <strong>Esc</strong> จะทำให้ออกจาก\n' +
|
||||
' การนำทางด้วยแป้นพิมพ์ทั้งหมดเช่นกัน</p>\n' +
|
||||
'\n' +
|
||||
'<h1>การดำเนินการรายการเมนูหรือปุ่มในแถบเครื่องมือ</h1>\n' +
|
||||
'\n' +
|
||||
'<p>เมื่อไฮไลต์รายการเมนูหรือปุ่มในแถบเครื่องมือที่ต้องการ ให้กด <strong>Return</strong>, <strong>Enter</strong>\n' +
|
||||
' หรือ <strong>Space bar</strong> เพื่อดำเนินการรายการดังกล่าว</p>\n' +
|
||||
'\n' +
|
||||
'<h1>การนำทางสำหรับกล่องโต้ตอบที่ไม่อยู่ในแท็บ</h1>\n' +
|
||||
'\n' +
|
||||
'<p>ในกล่องโต้ตอบที่ไม่อยู่ในแท็บ จะโฟกัสที่ส่วนประกอบเชิงโต้ตอบแรกเมื่อกล่องโต้ตอบเปิด</p>\n' +
|
||||
'\n' +
|
||||
'<p>นำทางระหว่างส่วนประกอบเชิงโต้ตอบต่างๆ ของกล่องโต้ตอบ โดยการกด <strong>Tab</strong> หรือ <strong>Shift+Tab</strong></p>\n' +
|
||||
'\n' +
|
||||
'<h1>การนำทางสำหรับกล่องโต้ตอบที่อยู่ในแท็บ</h1>\n' +
|
||||
'\n' +
|
||||
'<p>ในกล่องโต้ตอบที่อยู่ในแท็บ จะโฟกัสที่ปุ่มแรกในเมนูแท็บเมื่อกล่องโต้ตอบเปิด</p>\n' +
|
||||
'\n' +
|
||||
'<p>นำทางระหว่างส่วนประกอบเชิงโต้ตอบต่างๆ ของแท็บกล่องโต้ตอบนี้โดยการกด <strong>Tab</strong> หรือ\n' +
|
||||
' <strong>Shift+Tab</strong></p>\n' +
|
||||
'\n' +
|
||||
'<p>สลับไปยังแท็บกล่องโต้ตอบอื่นโดยการเลือกโฟกัสที่เมนูแท็บ แล้วกดปุ่ม<strong>ลูกศร</strong>ที่เหมาะสม\n' +
|
||||
' เพื่อเลือกแท็บที่ใช้ได้</p>\n');
|
||||
@@ -0,0 +1,87 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.zh-CN',
|
||||
'<h1>开始键盘导航</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>使菜单栏处于焦点</dt>\n' +
|
||||
' <dd>Windows 或 Linux:Alt+F9</dd>\n' +
|
||||
' <dd>macOS:⌥F9</dd>\n' +
|
||||
' <dt>使工具栏处于焦点</dt>\n' +
|
||||
' <dd>Windows 或 Linux:Alt+F10</dd>\n' +
|
||||
' <dd>macOS:⌥F10</dd>\n' +
|
||||
' <dt>使页脚处于焦点</dt>\n' +
|
||||
' <dd>Windows 或 Linux:Alt+F11</dd>\n' +
|
||||
' <dd>macOS:⌥F11</dd>\n' +
|
||||
' <dt>使通知处于焦点</dt>\n' +
|
||||
' <dd>Windows 或 Linux:Alt+F12</dd>\n' +
|
||||
' <dd>macOS:⌥F12</dd>\n' +
|
||||
' <dt>使上下文工具栏处于焦点</dt>\n' +
|
||||
' <dd>Windows、Linux 或 macOS:Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>导航将在第一个 UI 项上开始,其中突出显示该项,或者对于页脚元素路径中的第一项,将为其添加下划线。</p>\n' +
|
||||
'\n' +
|
||||
'<h1>在 UI 部分之间导航</h1>\n' +
|
||||
'\n' +
|
||||
'<p>要从一个 UI 部分移至下一个,请按 <strong>Tab</strong>。</p>\n' +
|
||||
'\n' +
|
||||
'<p>要从一个 UI 部分移至上一个,请按 <strong>Shift+Tab</strong>。</p>\n' +
|
||||
'\n' +
|
||||
'<p>这些 UI 部分的 <strong>Tab</strong> 顺序为:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>菜单栏</li>\n' +
|
||||
' <li>每个工具栏组</li>\n' +
|
||||
' <li>边栏</li>\n' +
|
||||
' <li>页脚中的元素路径</li>\n' +
|
||||
' <li>页脚中的字数切换按钮</li>\n' +
|
||||
' <li>页脚中的品牌链接</li>\n' +
|
||||
' <li>页脚中的编辑器调整大小图柄</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>如果不存在某个 UI 部分,则跳过它。</p>\n' +
|
||||
'\n' +
|
||||
'<p>如果键盘导航焦点在页脚,并且没有可见的边栏,则按 <strong>Shift+Tab</strong> 将焦点移至第一个工具栏组而非最后一个。</p>\n' +
|
||||
'\n' +
|
||||
'<h1>在 UI 部分内导航</h1>\n' +
|
||||
'\n' +
|
||||
'<p>要从一个 UI 元素移至下一个,请按相应的<strong>箭头</strong>键。</p>\n' +
|
||||
'\n' +
|
||||
'<p><strong>左</strong>和<strong>右</strong>箭头键</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>在菜单栏中的菜单之间移动。</li>\n' +
|
||||
' <li>打开菜单中的子菜单。</li>\n' +
|
||||
' <li>在工具栏组中的按钮之间移动。</li>\n' +
|
||||
' <li>在页脚的元素路径中的各项之间移动。</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>下</strong>和<strong>上</strong>箭头键</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>在菜单中的菜单项之间移动。</li>\n' +
|
||||
' <li>在工具栏弹出菜单中的各项之间移动。</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>箭头</strong>键在具有焦点的 UI 部分内循环。</p>\n' +
|
||||
'\n' +
|
||||
'<p>要关闭打开的菜单、打开的子菜单或打开的弹出菜单,请按 <strong>Esc</strong> 键。</p>\n' +
|
||||
'\n' +
|
||||
'<p>如果当前的焦点在特定 UI 部分的“顶部”,则按 <strong>Esc</strong> 键还将完全退出键盘导航。</p>\n' +
|
||||
'\n' +
|
||||
'<h1>执行菜单项或工具栏按钮</h1>\n' +
|
||||
'\n' +
|
||||
'<p>当突出显示所需的菜单项或工具栏按钮时,按 <strong>Return</strong>、<strong>Enter</strong> 或<strong>空格</strong>以执行该项。</p>\n' +
|
||||
'\n' +
|
||||
'<h1>在非标签页式对话框中导航</h1>\n' +
|
||||
'\n' +
|
||||
'<p>在非标签页式对话框中,当对话框打开时,第一个交互组件获得焦点。</p>\n' +
|
||||
'\n' +
|
||||
'<p>通过按 <strong>Tab</strong> 或 <strong>Shift+Tab</strong>,在交互对话框组件之间导航。</p>\n' +
|
||||
'\n' +
|
||||
'<h1>在标签页式对话框中导航</h1>\n' +
|
||||
'\n' +
|
||||
'<p>在标签页式对话框中,当对话框打开时,标签页菜单中的第一个按钮获得焦点。</p>\n' +
|
||||
'\n' +
|
||||
'<p>通过按 <strong>Tab</strong> 或 <strong>Shift+Tab</strong>,在此对话框的交互组件之间导航。</p>\n' +
|
||||
'\n' +
|
||||
'<p>通过将焦点移至另一对话框标签页的菜单,然后按相应的<strong>箭头</strong>键以在可用的标签页间循环,从而切换到该对话框标签页。</p>\n');
|
||||
@@ -0,0 +1,93 @@
|
||||
tinymce.Resource.add('tinymce.html-i18n.help-keynav.zh-TW',
|
||||
'<h1>開始鍵盤瀏覽</h1>\n' +
|
||||
'\n' +
|
||||
'<dl>\n' +
|
||||
' <dt>跳至功能表列</dt>\n' +
|
||||
' <dd>Windows 或 Linux:Alt+F9</dd>\n' +
|
||||
' <dd>macOS:⌥F9</dd>\n' +
|
||||
' <dt>跳至工具列</dt>\n' +
|
||||
' <dd>Windows 或 Linux:Alt+F10</dd>\n' +
|
||||
' <dd>macOS:⌥F10</dd>\n' +
|
||||
' <dt>跳至頁尾</dt>\n' +
|
||||
' <dd>Windows 或 Linux:Alt+F11</dd>\n' +
|
||||
' <dd>macOS:⌥F11</dd>\n' +
|
||||
' <dt>跳至通知</dt>\n' +
|
||||
' <dd>Windows 或 Linux:Alt+F12</dd>\n' +
|
||||
' <dd>macOS:⌥F12</dd>\n' +
|
||||
' <dt>跳至關聯式工具列</dt>\n' +
|
||||
' <dd>Windows、Linux 或 macOS:Ctrl+F9</dd>\n' +
|
||||
'</dl>\n' +
|
||||
'\n' +
|
||||
'<p>瀏覽會從第一個 UI 項目開始,該項目會反白顯示,但如果是「頁尾」元素路徑的第一項,\n' +
|
||||
' 則加底線。</p>\n' +
|
||||
'\n' +
|
||||
'<h1>在 UI 區段之間瀏覽</h1>\n' +
|
||||
'\n' +
|
||||
'<p>從 UI 區段移至下一個,請按 <strong>Tab</strong>。</p>\n' +
|
||||
'\n' +
|
||||
'<p>從 UI 區段移回上一個,請按 <strong>Shift+Tab</strong>。</p>\n' +
|
||||
'\n' +
|
||||
'<p>這些 UI 區段的 <strong>Tab</strong> 順序如下:</p>\n' +
|
||||
'\n' +
|
||||
'<ol>\n' +
|
||||
' <li>功能表列</li>\n' +
|
||||
' <li>各個工具列群組</li>\n' +
|
||||
' <li>側邊欄</li>\n' +
|
||||
' <li>頁尾中的元素路徑</li>\n' +
|
||||
' <li>頁尾中字數切換按鈕</li>\n' +
|
||||
' <li>頁尾中的品牌連結</li>\n' +
|
||||
' <li>頁尾中編輯器調整大小控點</li>\n' +
|
||||
'</ol>\n' +
|
||||
'\n' +
|
||||
'<p>如果 UI 區段未顯示,表示已略過該區段。</p>\n' +
|
||||
'\n' +
|
||||
'<p>如果鍵盤瀏覽跳至頁尾,但沒有顯示側邊欄,則按下 <strong>Shift+Tab</strong>\n' +
|
||||
' 會跳至第一個工具列群組,而不是最後一個。</p>\n' +
|
||||
'\n' +
|
||||
'<h1>在 UI 區段之內瀏覽</h1>\n' +
|
||||
'\n' +
|
||||
'<p>在兩個 UI 元素之間移動,請按適當的<strong>方向</strong>鍵。</p>\n' +
|
||||
'\n' +
|
||||
'<p><strong>向左</strong>和<strong>向右</strong>方向鍵</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>在功能表列中的功能表之間移動。</li>\n' +
|
||||
' <li>開啟功能表中的子功能表。</li>\n' +
|
||||
' <li>在工具列群組中的按鈕之間移動。</li>\n' +
|
||||
' <li>在頁尾的元素路徑中項目之間移動。</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>向下</strong>和<strong>向上</strong>方向鍵</p>\n' +
|
||||
'\n' +
|
||||
'<ul>\n' +
|
||||
' <li>在功能表中的功能表項目之間移動。</li>\n' +
|
||||
' <li>在工具列快顯功能表中的項目之間移動。</li>\n' +
|
||||
'</ul>\n' +
|
||||
'\n' +
|
||||
'<p><strong>方向</strong>鍵會在所跳至 UI 區段之內循環。</p>\n' +
|
||||
'\n' +
|
||||
'<p>若要關閉已開啟的功能表、已開啟的子功能表,或已開啟的快顯功能表,請按 <strong>Esc</strong> 鍵。</p>\n' +
|
||||
'\n' +
|
||||
'<p>如果目前已跳至特定 UI 區段的「頂端」,則按 <strong>Esc</strong> 鍵也會結束\n' +
|
||||
' 整個鍵盤瀏覽。</p>\n' +
|
||||
'\n' +
|
||||
'<h1>執行功能表列項目或工具列按鈕</h1>\n' +
|
||||
'\n' +
|
||||
'<p>當想要的功能表項目或工具列按鈕已反白顯示時,按 <strong>Return</strong>、<strong>Enter</strong>、\n' +
|
||||
' 或<strong>空白鍵</strong>即可執行該項目。</p>\n' +
|
||||
'\n' +
|
||||
'<h1>瀏覽非索引標籤式對話方塊</h1>\n' +
|
||||
'\n' +
|
||||
'<p>在非索引標籤式對話方塊中,開啟對話方塊時會跳至第一個互動元件。</p>\n' +
|
||||
'\n' +
|
||||
'<p>按 <strong>Tab</strong> 或 <strong>Shift+Tab</strong> 即可在互動式對話方塊元件之間瀏覽。</p>\n' +
|
||||
'\n' +
|
||||
'<h1>瀏覽索引標籤式對話方塊</h1>\n' +
|
||||
'\n' +
|
||||
'<p>在索引標籤式對話方塊中,開啟對話方塊時會跳至索引標籤式功能表中的第一個按鈕。</p>\n' +
|
||||
'\n' +
|
||||
'<p>若要在此對話方塊的互動式元件之間瀏覽,請按 <strong>Tab</strong> 或\n' +
|
||||
' <strong>Shift+Tab</strong>。</p>\n' +
|
||||
'\n' +
|
||||
'<p>先跳至索引標籤式功能表,然後按適當的<strong>方向</strong>鍵,即可切換至另一個對話方塊索引標籤,\n' +
|
||||
' 以循環瀏覽可用的索引標籤。</p>\n');
|
||||
@@ -0,0 +1,827 @@
|
||||
/**
|
||||
* TinyMCE version 8.5.1 (2026-05-19)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-wrapper-object-types */
|
||||
const hasProto = (v, constructor, predicate) => {
|
||||
if (predicate(v, constructor.prototype)) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
// String-based fallback time
|
||||
return v.constructor?.name === constructor.name;
|
||||
}
|
||||
};
|
||||
const typeOf = (x) => {
|
||||
const t = typeof x;
|
||||
if (x === null) {
|
||||
return 'null';
|
||||
}
|
||||
else if (t === 'object' && Array.isArray(x)) {
|
||||
return 'array';
|
||||
}
|
||||
else if (t === 'object' && hasProto(x, String, (o, proto) => proto.isPrototypeOf(o))) {
|
||||
return 'string';
|
||||
}
|
||||
else {
|
||||
return t;
|
||||
}
|
||||
};
|
||||
const isType = (type) => (value) => typeOf(value) === type;
|
||||
const isSimpleType = (type) => (value) => typeof value === type;
|
||||
const eq = (t) => (a) => t === a;
|
||||
const isString = isType('string');
|
||||
const isUndefined = eq(undefined);
|
||||
const isNullable = (a) => a === null || a === undefined;
|
||||
const isNonNullable = (a) => !isNullable(a);
|
||||
const isFunction = isSimpleType('function');
|
||||
|
||||
const constant = (value) => {
|
||||
return () => {
|
||||
return value;
|
||||
};
|
||||
};
|
||||
const never = constant(false);
|
||||
|
||||
/**
|
||||
* The `Optional` type represents a value (of any type) that potentially does
|
||||
* not exist. Any `Optional<T>` can either be a `Some<T>` (in which case the
|
||||
* value does exist) or a `None` (in which case the value does not exist). This
|
||||
* module defines a whole lot of FP-inspired utility functions for dealing with
|
||||
* `Optional` objects.
|
||||
*
|
||||
* Comparison with null or undefined:
|
||||
* - We don't get fancy null coalescing operators with `Optional`
|
||||
* - We do get fancy helper functions with `Optional`
|
||||
* - `Optional` support nesting, and allow for the type to still be nullable (or
|
||||
* another `Optional`)
|
||||
* - There is no option to turn off strict-optional-checks like there is for
|
||||
* strict-null-checks
|
||||
*/
|
||||
class Optional {
|
||||
tag;
|
||||
value;
|
||||
// Sneaky optimisation: every instance of Optional.none is identical, so just
|
||||
// reuse the same object
|
||||
static singletonNone = new Optional(false);
|
||||
// The internal representation has a `tag` and a `value`, but both are
|
||||
// private: able to be console.logged, but not able to be accessed by code
|
||||
constructor(tag, value) {
|
||||
this.tag = tag;
|
||||
this.value = value;
|
||||
}
|
||||
// --- Identities ---
|
||||
/**
|
||||
* Creates a new `Optional<T>` that **does** contain a value.
|
||||
*/
|
||||
static some(value) {
|
||||
return new Optional(true, value);
|
||||
}
|
||||
/**
|
||||
* Create a new `Optional<T>` that **does not** contain a value. `T` can be
|
||||
* any type because we don't actually have a `T`.
|
||||
*/
|
||||
static none() {
|
||||
return Optional.singletonNone;
|
||||
}
|
||||
/**
|
||||
* Perform a transform on an `Optional` type. Regardless of whether this
|
||||
* `Optional` contains a value or not, `fold` will return a value of type `U`.
|
||||
* If this `Optional` does not contain a value, the `U` will be created by
|
||||
* calling `onNone`. If this `Optional` does contain a value, the `U` will be
|
||||
* created by calling `onSome`.
|
||||
*
|
||||
* For the FP enthusiasts in the room, this function:
|
||||
* 1. Could be used to implement all of the functions below
|
||||
* 2. Forms a catamorphism
|
||||
*/
|
||||
fold(onNone, onSome) {
|
||||
if (this.tag) {
|
||||
return onSome(this.value);
|
||||
}
|
||||
else {
|
||||
return onNone();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Determine if this `Optional` object contains a value.
|
||||
*/
|
||||
isSome() {
|
||||
return this.tag;
|
||||
}
|
||||
/**
|
||||
* Determine if this `Optional` object **does not** contain a value.
|
||||
*/
|
||||
isNone() {
|
||||
return !this.tag;
|
||||
}
|
||||
// --- Functor (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* Perform a transform on an `Optional` object, **if** there is a value. If
|
||||
* you provide a function to turn a T into a U, this is the function you use
|
||||
* to turn an `Optional<T>` into an `Optional<U>`. If this **does** contain
|
||||
* a value then the output will also contain a value (that value being the
|
||||
* output of `mapper(this.value)`), and if this **does not** contain a value
|
||||
* then neither will the output.
|
||||
*/
|
||||
map(mapper) {
|
||||
if (this.tag) {
|
||||
return Optional.some(mapper(this.value));
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Monad (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* Perform a transform on an `Optional` object, **if** there is a value.
|
||||
* Unlike `map`, here the transform itself also returns an `Optional`.
|
||||
*/
|
||||
bind(binder) {
|
||||
if (this.tag) {
|
||||
return binder(this.value);
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Traversable (name stolen from Haskell / maths) ---
|
||||
/**
|
||||
* For a given predicate, this function finds out if there **exists** a value
|
||||
* inside this `Optional` object that meets the predicate. In practice, this
|
||||
* means that for `Optional`s that do not contain a value it returns false (as
|
||||
* no predicate-meeting value exists).
|
||||
*/
|
||||
exists(predicate) {
|
||||
return this.tag && predicate(this.value);
|
||||
}
|
||||
/**
|
||||
* For a given predicate, this function finds out if **all** the values inside
|
||||
* this `Optional` object meet the predicate. In practice, this means that
|
||||
* for `Optional`s that do not contain a value it returns true (as all 0
|
||||
* objects do meet the predicate).
|
||||
*/
|
||||
forall(predicate) {
|
||||
return !this.tag || predicate(this.value);
|
||||
}
|
||||
filter(predicate) {
|
||||
if (!this.tag || predicate(this.value)) {
|
||||
return this;
|
||||
}
|
||||
else {
|
||||
return Optional.none();
|
||||
}
|
||||
}
|
||||
// --- Getters ---
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value.
|
||||
*/
|
||||
getOr(replacement) {
|
||||
return this.tag ? this.value : replacement;
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value. Unlike `getOr`, in this method the `replacement` object is also
|
||||
* `Optional` - meaning that this method will always return an `Optional`.
|
||||
*/
|
||||
or(replacement) {
|
||||
return this.tag ? this : replacement;
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided `Optional` object does not contain a
|
||||
* value. Unlike `getOr`, in this method the `replacement` value is
|
||||
* "thunked" - that is to say that you don't pass a value to `getOrThunk`, you
|
||||
* pass a function which (if called) will **return** the `value` you want to
|
||||
* use.
|
||||
*/
|
||||
getOrThunk(thunk) {
|
||||
return this.tag ? this.value : thunk();
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, using a default
|
||||
* `replacement` value if the provided Optional object does not contain a
|
||||
* value.
|
||||
*
|
||||
* Unlike `or`, in this method the `replacement` value is "thunked" - that is
|
||||
* to say that you don't pass a value to `orThunk`, you pass a function which
|
||||
* (if called) will **return** the `value` you want to use.
|
||||
*
|
||||
* Unlike `getOrThunk`, in this method the `replacement` value is also
|
||||
* `Optional`, meaning that this method will always return an `Optional`.
|
||||
*/
|
||||
orThunk(thunk) {
|
||||
return this.tag ? this : thunk();
|
||||
}
|
||||
/**
|
||||
* Get the value out of the inside of the `Optional` object, throwing an
|
||||
* exception if the provided `Optional` object does not contain a value.
|
||||
*
|
||||
* WARNING:
|
||||
* You should only be using this function if you know that the `Optional`
|
||||
* object **is not** empty (otherwise you're throwing exceptions in production
|
||||
* code, which is bad).
|
||||
*
|
||||
* In tests this is more acceptable.
|
||||
*
|
||||
* Prefer other methods to this, such as `.each`.
|
||||
*/
|
||||
getOrDie(message) {
|
||||
if (!this.tag) {
|
||||
throw new Error(message ?? 'Called getOrDie on None');
|
||||
}
|
||||
else {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
// --- Interop with null and undefined ---
|
||||
/**
|
||||
* Creates an `Optional` value from a nullable (or undefined-able) input.
|
||||
* Null, or undefined, is converted to `None`, and anything else is converted
|
||||
* to `Some`.
|
||||
*/
|
||||
static from(value) {
|
||||
return isNonNullable(value) ? Optional.some(value) : Optional.none();
|
||||
}
|
||||
/**
|
||||
* Converts an `Optional` to a nullable type, by getting the value if it
|
||||
* exists, or returning `null` if it does not.
|
||||
*/
|
||||
getOrNull() {
|
||||
return this.tag ? this.value : null;
|
||||
}
|
||||
/**
|
||||
* Converts an `Optional` to an undefined-able type, by getting the value if
|
||||
* it exists, or returning `undefined` if it does not.
|
||||
*/
|
||||
getOrUndefined() {
|
||||
return this.value;
|
||||
}
|
||||
// --- Utilities ---
|
||||
/**
|
||||
* If the `Optional` contains a value, perform an action on that value.
|
||||
* Unlike the rest of the methods on this type, `.each` has side-effects. If
|
||||
* you want to transform an `Optional<T>` **into** something, then this is not
|
||||
* the method for you. If you want to use an `Optional<T>` to **do**
|
||||
* something, then this is the method for you - provided you're okay with not
|
||||
* doing anything in the case where the `Optional` doesn't have a value inside
|
||||
* it. If you're not sure whether your use-case fits into transforming
|
||||
* **into** something or **doing** something, check whether it has a return
|
||||
* value. If it does, you should be performing a transform.
|
||||
*/
|
||||
each(worker) {
|
||||
if (this.tag) {
|
||||
worker(this.value);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Turn the `Optional` object into an array that contains all of the values
|
||||
* stored inside the `Optional`. In practice, this means the output will have
|
||||
* either 0 or 1 elements.
|
||||
*/
|
||||
toArray() {
|
||||
return this.tag ? [this.value] : [];
|
||||
}
|
||||
/**
|
||||
* Turn the `Optional` object into a string for debugging or printing. Not
|
||||
* recommended for production code, but good for debugging. Also note that
|
||||
* these days an `Optional` object can be logged to the console directly, and
|
||||
* its inner value (if it exists) will be visible.
|
||||
*/
|
||||
toString() {
|
||||
return this.tag ? `some(${this.value})` : 'none()';
|
||||
}
|
||||
}
|
||||
|
||||
const nativeSlice = Array.prototype.slice;
|
||||
const nativeIndexOf = Array.prototype.indexOf;
|
||||
const rawIndexOf = (ts, t) => nativeIndexOf.call(ts, t);
|
||||
const contains = (xs, x) => rawIndexOf(xs, x) > -1;
|
||||
const map = (xs, f) => {
|
||||
// pre-allocating array size when it's guaranteed to be known
|
||||
// http://jsperf.com/push-allocated-vs-dynamic/22
|
||||
const len = xs.length;
|
||||
const r = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = xs[i];
|
||||
r[i] = f(x, i);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
const filter = (xs, pred) => {
|
||||
const r = [];
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
if (pred(x, i)) {
|
||||
r.push(x);
|
||||
}
|
||||
}
|
||||
return r;
|
||||
};
|
||||
const findUntil = (xs, pred, until) => {
|
||||
for (let i = 0, len = xs.length; i < len; i++) {
|
||||
const x = xs[i];
|
||||
if (pred(x, i)) {
|
||||
return Optional.some(x);
|
||||
}
|
||||
else if (until(x, i)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Optional.none();
|
||||
};
|
||||
const find = (xs, pred) => {
|
||||
return findUntil(xs, pred, never);
|
||||
};
|
||||
const sort = (xs, comparator) => {
|
||||
const copy = nativeSlice.call(xs, 0);
|
||||
copy.sort(comparator);
|
||||
return copy;
|
||||
};
|
||||
isFunction(Array.from) ? Array.from : (x) => nativeSlice.call(x);
|
||||
|
||||
// There are many variations of Object iteration that are faster than the 'for-in' style:
|
||||
// http://jsperf.com/object-keys-iteration/107
|
||||
//
|
||||
// Use the native keys if it is available (IE9+), otherwise fall back to manually filtering
|
||||
const keys = Object.keys;
|
||||
const hasOwnProperty = Object.hasOwnProperty;
|
||||
const get$1 = (obj, key) => {
|
||||
return has(obj, key) ? Optional.from(obj[key]) : Optional.none();
|
||||
};
|
||||
const has = (obj, key) => hasOwnProperty.call(obj, key);
|
||||
|
||||
const Cell = (initial) => {
|
||||
let value = initial;
|
||||
const get = () => {
|
||||
return value;
|
||||
};
|
||||
const set = (v) => {
|
||||
value = v;
|
||||
};
|
||||
return {
|
||||
get,
|
||||
set
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds two numbers, and wrap to a range.
|
||||
* If the result overflows to the right, snap to the left.
|
||||
* If the result overflows to the left, snap to the right.
|
||||
*/
|
||||
// the division is meant to get a number between 0 and 1 for more information check this discussion: https://stackoverflow.com/questions/58285941/how-to-replace-math-random-with-crypto-getrandomvalues-and-keep-same-result
|
||||
const random = () => window.crypto.getRandomValues(new Uint32Array(1))[0] / 4294967295;
|
||||
|
||||
/**
|
||||
* Generate a unique identifier.
|
||||
*
|
||||
* The unique portion of the identifier only contains an underscore
|
||||
* and digits, so that it may safely be used within HTML attributes.
|
||||
*
|
||||
* The chance of generating a non-unique identifier has been minimized
|
||||
* by combining the current time, a random number and a one-up counter.
|
||||
*
|
||||
* generate :: String -> String
|
||||
*/
|
||||
let unique = 0;
|
||||
const generate = (prefix) => {
|
||||
const date = new Date();
|
||||
const time = date.getTime();
|
||||
const random$1 = Math.floor(random() * 1000000000);
|
||||
unique++;
|
||||
return prefix + '_' + random$1 + unique + String(time);
|
||||
};
|
||||
|
||||
const cat = (arr) => {
|
||||
const r = [];
|
||||
const push = (x) => {
|
||||
r.push(x);
|
||||
};
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
arr[i].each(push);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
var global$4 = tinymce.util.Tools.resolve('tinymce.PluginManager');
|
||||
|
||||
const get = (customTabs) => {
|
||||
const addTab = (spec) => {
|
||||
const name = spec.name ?? generate('tab-name');
|
||||
const currentCustomTabs = customTabs.get();
|
||||
currentCustomTabs[name] = spec;
|
||||
customTabs.set(currentCustomTabs);
|
||||
};
|
||||
return {
|
||||
addTab
|
||||
};
|
||||
};
|
||||
|
||||
const register$2 = (editor, dialogOpener) => {
|
||||
editor.addCommand('mceHelp', dialogOpener);
|
||||
};
|
||||
|
||||
const option = (name) => (editor) => editor.options.get(name);
|
||||
const register$1 = (editor) => {
|
||||
const registerOption = editor.options.register;
|
||||
registerOption('help_tabs', {
|
||||
processor: 'array'
|
||||
});
|
||||
};
|
||||
const getHelpTabs = option('help_tabs');
|
||||
const getForcedPlugins = option('forced_plugins');
|
||||
|
||||
const register = (editor, dialogOpener) => {
|
||||
editor.ui.registry.addButton('help', {
|
||||
icon: 'help',
|
||||
tooltip: 'Help',
|
||||
onAction: dialogOpener,
|
||||
context: 'any'
|
||||
});
|
||||
editor.ui.registry.addMenuItem('help', {
|
||||
text: 'Help',
|
||||
icon: 'help',
|
||||
shortcut: 'Alt+0',
|
||||
onAction: dialogOpener,
|
||||
context: 'any'
|
||||
});
|
||||
};
|
||||
|
||||
var global$3 = tinymce.util.Tools.resolve('tinymce.Resource');
|
||||
|
||||
var global$2 = tinymce.util.Tools.resolve('tinymce.util.I18n');
|
||||
|
||||
const pLoadHtmlByLangCode = (baseUrl, langCode) => global$3.load(`tinymce.html-i18n.help-keynav.${langCode}`, `${baseUrl}/js/i18n/keynav/${langCode}.js`);
|
||||
const pLoadI18nHtml = (baseUrl) =>
|
||||
// TINY-9928: Load language file for the current language, or English if the file is not available
|
||||
pLoadHtmlByLangCode(baseUrl, global$2.getCode()).catch(() => pLoadHtmlByLangCode(baseUrl, 'en'));
|
||||
const initI18nLoad = (editor, baseUrl) => {
|
||||
editor.on('init', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
pLoadI18nHtml(baseUrl);
|
||||
});
|
||||
};
|
||||
|
||||
const pTab = async (pluginUrl) => {
|
||||
const body = {
|
||||
type: 'htmlpanel',
|
||||
presets: 'document',
|
||||
html: await pLoadI18nHtml(pluginUrl)
|
||||
};
|
||||
return {
|
||||
name: 'keyboardnav',
|
||||
title: 'Keyboard Navigation',
|
||||
items: [body]
|
||||
};
|
||||
};
|
||||
|
||||
var global$1 = tinymce.util.Tools.resolve('tinymce.Env');
|
||||
|
||||
// Converts shortcut format to Mac/PC variants
|
||||
const convertText = (source) => {
|
||||
const isMac = global$1.os.isMacOS() || global$1.os.isiOS();
|
||||
const mac = {
|
||||
alt: '⌥',
|
||||
ctrl: '⌃',
|
||||
shift: '⇧',
|
||||
meta: '⌘',
|
||||
access: '⌃⌥'
|
||||
};
|
||||
const other = {
|
||||
meta: 'Ctrl ',
|
||||
access: 'Shift + Alt '
|
||||
};
|
||||
const replace = isMac ? mac : other;
|
||||
const shortcut = source.split('+');
|
||||
const updated = map(shortcut, (segment) => {
|
||||
// search lowercase, but if not found use the original
|
||||
const search = segment.toLowerCase().trim();
|
||||
return has(replace, search) ? replace[search] : segment;
|
||||
});
|
||||
return isMac ? (updated.join('')).replace(/\s/, '') : updated.join('+');
|
||||
};
|
||||
|
||||
const shortcuts = [
|
||||
{ shortcuts: ['Meta + B'], action: 'Bold' },
|
||||
{ shortcuts: ['Meta + I'], action: 'Italic' },
|
||||
{ shortcuts: ['Meta + U'], action: 'Underline' },
|
||||
{ shortcuts: ['Meta + A'], action: 'Select all' },
|
||||
{ shortcuts: ['Meta + Y', 'Meta + Shift + Z'], action: 'Redo' },
|
||||
{ shortcuts: ['Meta + Z'], action: 'Undo' },
|
||||
{ shortcuts: ['Access + 1'], action: 'Heading 1' },
|
||||
{ shortcuts: ['Access + 2'], action: 'Heading 2' },
|
||||
{ shortcuts: ['Access + 3'], action: 'Heading 3' },
|
||||
{ shortcuts: ['Access + 4'], action: 'Heading 4' },
|
||||
{ shortcuts: ['Access + 5'], action: 'Heading 5' },
|
||||
{ shortcuts: ['Access + 6'], action: 'Heading 6' },
|
||||
{ shortcuts: ['Access + 7'], action: 'Paragraph' },
|
||||
{ shortcuts: ['Access + 8'], action: 'Div' },
|
||||
{ shortcuts: ['Access + 9'], action: 'Address' },
|
||||
{ shortcuts: ['Alt + 0'], action: 'Open help dialog' },
|
||||
{ shortcuts: ['Alt + F9'], action: 'Focus to menubar' },
|
||||
{ shortcuts: ['Alt + F10'], action: 'Focus to toolbar' },
|
||||
{ shortcuts: ['Alt + F11'], action: 'Focus to element path' },
|
||||
{ shortcuts: ['Alt + F12'], action: 'Focus to notification' },
|
||||
{ shortcuts: ['Ctrl + F9'], action: 'Focus to contextual toolbar' },
|
||||
{ shortcuts: ['Shift + Enter'], action: 'Open popup menu for split buttons' },
|
||||
{ shortcuts: ['Meta + K'], action: 'Insert link (if link plugin activated)' },
|
||||
{ shortcuts: ['Meta + S'], action: 'Save (if save plugin activated)' },
|
||||
{ shortcuts: ['Meta + F'], action: 'Find (if searchreplace plugin activated)' },
|
||||
{ shortcuts: ['Meta + Shift + F'], action: 'Switch to or from fullscreen mode' }
|
||||
];
|
||||
|
||||
const tab$2 = () => {
|
||||
const shortcutList = map(shortcuts, (shortcut) => {
|
||||
const shortcutText = map(shortcut.shortcuts, convertText).join(' or ');
|
||||
return [shortcut.action, shortcutText];
|
||||
});
|
||||
const tablePanel = {
|
||||
type: 'table',
|
||||
// TODO: Fix table styles #TINY-2909
|
||||
header: ['Action', 'Shortcut'],
|
||||
cells: shortcutList
|
||||
};
|
||||
return {
|
||||
name: 'shortcuts',
|
||||
title: 'Handy Shortcuts',
|
||||
items: [
|
||||
tablePanel
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
// These lists are automatically sorted when generating the dialog.
|
||||
const urls = map([
|
||||
{ key: 'accordion', name: 'Accordion' },
|
||||
{ key: 'anchor', name: 'Anchor' },
|
||||
{ key: 'autolink', name: 'Autolink' },
|
||||
{ key: 'autoresize', name: 'Autoresize' },
|
||||
{ key: 'autosave', name: 'Autosave' },
|
||||
{ key: 'charmap', name: 'Character Map' },
|
||||
{ key: 'code', name: 'Code' },
|
||||
{ key: 'codesample', name: 'Code Sample' },
|
||||
{ key: 'colorpicker', name: 'Color Picker' },
|
||||
{ key: 'directionality', name: 'Directionality' },
|
||||
{ key: 'emoticons', name: 'Emoticons' },
|
||||
{ key: 'fullscreen', name: 'Full Screen' },
|
||||
{ key: 'help', name: 'Help' },
|
||||
{ key: 'image', name: 'Image' },
|
||||
{ key: 'importcss', name: 'Import CSS' },
|
||||
{ key: 'insertdatetime', name: 'Insert Date/Time' },
|
||||
{ key: 'link', name: 'Link' },
|
||||
{ key: 'lists', name: 'Lists' },
|
||||
{ key: 'advlist', name: 'List Styles' },
|
||||
{ key: 'media', name: 'Media' },
|
||||
{ key: 'nonbreaking', name: 'Nonbreaking' },
|
||||
{ key: 'pagebreak', name: 'Page Break' },
|
||||
{ key: 'preview', name: 'Preview' },
|
||||
{ key: 'quickbars', name: 'Quick Toolbars' },
|
||||
{ key: 'save', name: 'Save' },
|
||||
{ key: 'searchreplace', name: 'Search and Replace' },
|
||||
{ key: 'table', name: 'Table' },
|
||||
{ key: 'textcolor', name: 'Text Color' },
|
||||
{ key: 'visualblocks', name: 'Visual Blocks' },
|
||||
{ key: 'visualchars', name: 'Visual Characters' },
|
||||
{ key: 'wordcount', name: 'Word Count' },
|
||||
// TODO: Add other premium plugins when they are included in the website
|
||||
{ key: 'a11ychecker', name: 'Accessibility Checker', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'typography', name: 'Advanced Typography', type: "premium" /* PluginType.Premium */, slug: 'advanced-typography' },
|
||||
{ key: 'ai', name: 'AI Assistant', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'casechange', name: 'Case Change', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'checklist', name: 'Checklist', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'advcode', name: 'Enhanced Code Editor', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'mediaembed', name: 'Enhanced Media Embed', type: "premium" /* PluginType.Premium */, slug: 'introduction-to-mediaembed' },
|
||||
{ key: 'advtable', name: 'Enhanced Tables', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'exportpdf', name: 'Export to PDF', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'exportword', name: 'Export to Word', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'footnotes', name: 'Footnotes', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'formatpainter', name: 'Format Painter', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'editimage', name: 'Image Editing', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'uploadcare', name: 'Image Optimizer Powered by Uploadcare', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'importword', name: 'Import from Word', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'inlinecss', name: 'Inline CSS', type: "premium" /* PluginType.Premium */, slug: 'inline-css' },
|
||||
{ key: 'linkchecker', name: 'Link Checker', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'math', name: 'Math', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'markdown', name: 'Markdown', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'mentions', name: 'Mentions', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'mergetags', name: 'Merge Tags', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'pageembed', name: 'Page Embed', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'permanentpen', name: 'Permanent Pen', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'powerpaste', name: 'PowerPaste', type: "premium" /* PluginType.Premium */, slug: 'introduction-to-powerpaste' },
|
||||
{ key: 'revisionhistory', name: 'Revision History', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'tinymcespellchecker', name: 'Spell Checker', type: "premium" /* PluginType.Premium */, slug: 'introduction-to-tiny-spellchecker' },
|
||||
{ key: 'suggestededits', name: 'Suggested Edits', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'tinymceai', name: 'TinyMCE AI', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'autocorrect', name: 'Spelling Autocorrect', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'tableofcontents', name: 'Table of Contents', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'fullpagehtml', name: 'Fullpage HTML', type: "premium" /* PluginType.Premium */ },
|
||||
{ key: 'advtemplate', name: 'Templates', type: "premium" /* PluginType.Premium */, slug: 'advanced-templates' },
|
||||
{ key: 'tinycomments', name: 'Tiny Comments', type: "premium" /* PluginType.Premium */, slug: 'introduction-to-tiny-comments' },
|
||||
{ key: 'tinydrive', name: 'Tiny Drive', type: "premium" /* PluginType.Premium */, slug: 'tinydrive-introduction' },
|
||||
], (item) => ({
|
||||
...item,
|
||||
// Set the defaults/fallbacks for the plugin urls
|
||||
type: item.type || "opensource" /* PluginType.OpenSource */,
|
||||
slug: item.slug || item.key
|
||||
}));
|
||||
|
||||
const tab$1 = (editor) => {
|
||||
const availablePlugins = () => {
|
||||
const premiumPlugins = filter(urls, ({ type }) => {
|
||||
return type === "premium" /* PluginUrls.PluginType.Premium */;
|
||||
});
|
||||
const sortedPremiumPlugins = sort(map(premiumPlugins, (p) => p.name), (s1, s2) => s1.localeCompare(s2));
|
||||
const premiumPluginList = map(sortedPremiumPlugins, (pluginName) => `<li>${pluginName}</li>`).join('');
|
||||
return '<div>' +
|
||||
'<p><b>' + global$2.translate('Premium plugins:') + '</b></p>' +
|
||||
'<ul>' +
|
||||
premiumPluginList +
|
||||
'<li class="tox-help__more-link" ">' +
|
||||
'<a href="https://www.tiny.cloud/pricing/?utm_campaign=help_dialog_plugin_tab&utm_source=tiny&utm_medium=referral&utm_term=read_more&utm_content=premium_plugin_heading" rel="noopener" target="_blank"' +
|
||||
' data-alloy-tabstop="true" tabindex="-1">' + global$2.translate('Learn more...') + '</a></li>' +
|
||||
'</ul>' +
|
||||
'</div>';
|
||||
};
|
||||
const makeLink = (p) => `<a data-alloy-tabstop="true" tabindex="-1" href="${p.url}" target="_blank" rel="noopener">${p.name}</a>`;
|
||||
const identifyUnknownPlugin = (editor, key) => {
|
||||
const getMetadata = editor.plugins[key].getMetadata;
|
||||
if (isFunction(getMetadata)) {
|
||||
const metadata = getMetadata();
|
||||
return { name: metadata.name, html: makeLink(metadata) };
|
||||
}
|
||||
else {
|
||||
return { name: key, html: key };
|
||||
}
|
||||
};
|
||||
const getPluginData = (editor, key) => find(urls, (x) => {
|
||||
return x.key === key;
|
||||
}).fold(() => {
|
||||
return identifyUnknownPlugin(editor, key);
|
||||
}, (x) => {
|
||||
// We know this plugin, so use our stored details.
|
||||
const name = x.type === "premium" /* PluginUrls.PluginType.Premium */ ? `${x.name}*` : x.name;
|
||||
const html = makeLink({ name, url: `https://www.tiny.cloud/docs/tinymce/${tinymce.majorVersion}/${x.slug}/` });
|
||||
return { name, html };
|
||||
});
|
||||
const getPluginKeys = (editor) => {
|
||||
const keys$1 = keys(editor.plugins);
|
||||
const forcedPlugins = getForcedPlugins(editor);
|
||||
const hiddenPlugins = isUndefined(forcedPlugins) ? ['onboarding', 'licensekeymanager'] : forcedPlugins.concat(['onboarding', 'licensekeymanager']);
|
||||
return filter(keys$1, (k) => !contains(hiddenPlugins, k));
|
||||
};
|
||||
const pluginLister = (editor) => {
|
||||
const pluginKeys = getPluginKeys(editor);
|
||||
const sortedPluginData = sort(map(pluginKeys, (k) => getPluginData(editor, k)), (pd1, pd2) => pd1.name.localeCompare(pd2.name));
|
||||
const pluginLis = map(sortedPluginData, (key) => {
|
||||
return '<li>' + key.html + '</li>';
|
||||
});
|
||||
const count = pluginLis.length;
|
||||
const pluginsString = pluginLis.join('');
|
||||
const html = '<p><b>' + global$2.translate(['Plugins installed ({0}):', count]) + '</b></p>' +
|
||||
'<ul>' + pluginsString + '</ul>';
|
||||
return html;
|
||||
};
|
||||
const installedPlugins = (editor) => {
|
||||
if (editor == null) {
|
||||
return '';
|
||||
}
|
||||
return '<div>' +
|
||||
pluginLister(editor) +
|
||||
'</div>';
|
||||
};
|
||||
const htmlPanel = {
|
||||
type: 'htmlpanel',
|
||||
presets: 'document',
|
||||
html: [
|
||||
installedPlugins(editor),
|
||||
availablePlugins()
|
||||
].join('')
|
||||
};
|
||||
return {
|
||||
name: 'plugins',
|
||||
title: 'Plugins',
|
||||
items: [
|
||||
htmlPanel
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
var global = tinymce.util.Tools.resolve('tinymce.EditorManager');
|
||||
|
||||
const tab = () => {
|
||||
const getVersion = (major, minor) => major.indexOf('@') === 0 ? 'X.X.X' : major + '.' + minor;
|
||||
const version = getVersion(global.majorVersion, global.minorVersion);
|
||||
const changeLogLink = '<a data-alloy-tabstop="true" tabindex="-1" href="https://www.tiny.cloud/docs/tinymce/8/changelog/?utm_campaign=help_dialog_version_tab&utm_source=tiny&utm_medium=referral" rel="noopener" target="_blank">TinyMCE ' + version + '</a>';
|
||||
const htmlPanel = {
|
||||
type: 'htmlpanel',
|
||||
html: '<p>' + global$2.translate(['You are using {0}', changeLogLink]) + '</p>',
|
||||
presets: 'document'
|
||||
};
|
||||
return {
|
||||
name: 'versions',
|
||||
title: 'Version',
|
||||
items: [
|
||||
htmlPanel
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
const parseHelpTabsSetting = (tabsFromSettings, tabs) => {
|
||||
const newTabs = {};
|
||||
const names = map(tabsFromSettings, (t) => {
|
||||
if (isString(t)) {
|
||||
// Code below shouldn't care if a tab name doesn't have a spec.
|
||||
// If we find it does, we'll need to make this smarter.
|
||||
// CustomTabsTest has a case for this.
|
||||
if (has(tabs, t)) {
|
||||
newTabs[t] = tabs[t];
|
||||
}
|
||||
return t;
|
||||
}
|
||||
else {
|
||||
const name = t.name ?? generate('tab-name');
|
||||
newTabs[name] = t;
|
||||
return name;
|
||||
}
|
||||
});
|
||||
return { tabs: newTabs, names };
|
||||
};
|
||||
const getNamesFromTabs = (tabs) => {
|
||||
const names = keys(tabs);
|
||||
// Move the versions tab to the end if it exists
|
||||
const idx = names.indexOf('versions');
|
||||
if (idx !== -1) {
|
||||
names.splice(idx, 1);
|
||||
names.push('versions');
|
||||
}
|
||||
return { tabs, names };
|
||||
};
|
||||
const pParseCustomTabs = async (editor, customTabs, pluginUrl) => {
|
||||
const shortcuts = tab$2();
|
||||
const nav = await pTab(pluginUrl);
|
||||
const plugins = tab$1(editor);
|
||||
const versions = tab();
|
||||
const tabs = {
|
||||
[shortcuts.name]: shortcuts,
|
||||
[nav.name]: nav,
|
||||
[plugins.name]: plugins,
|
||||
[versions.name]: versions,
|
||||
...customTabs.get()
|
||||
};
|
||||
return Optional.from(getHelpTabs(editor)).fold(() => getNamesFromTabs(tabs), (tabsFromSettings) => parseHelpTabsSetting(tabsFromSettings, tabs));
|
||||
};
|
||||
const init = (editor, customTabs, pluginUrl) => () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
pParseCustomTabs(editor, customTabs, pluginUrl).then(({ tabs, names }) => {
|
||||
const foundTabs = map(names, (name) => get$1(tabs, name));
|
||||
const dialogTabs = cat(foundTabs);
|
||||
const body = {
|
||||
type: 'tabpanel',
|
||||
tabs: dialogTabs
|
||||
};
|
||||
editor.windowManager.open({
|
||||
title: 'Help',
|
||||
size: 'medium',
|
||||
body,
|
||||
buttons: [
|
||||
{
|
||||
type: 'cancel',
|
||||
name: 'close',
|
||||
text: 'Close',
|
||||
primary: true
|
||||
}
|
||||
],
|
||||
initialData: {}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
var Plugin = () => {
|
||||
global$4.add('help', (editor, pluginUrl) => {
|
||||
const customTabs = Cell({});
|
||||
const api = get(customTabs);
|
||||
register$1(editor);
|
||||
const dialogOpener = init(editor, customTabs, pluginUrl);
|
||||
register(editor, dialogOpener);
|
||||
register$2(editor, dialogOpener);
|
||||
editor.shortcuts.add('Alt+0', 'Open help dialog', 'mceHelp');
|
||||
initI18nLoad(editor, pluginUrl);
|
||||
return api;
|
||||
});
|
||||
};
|
||||
|
||||
Plugin();
|
||||
/** *****
|
||||
* DO NOT EXPORT ANYTHING
|
||||
*
|
||||
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
|
||||
*******/
|
||||
|
||||
})();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user