├── .gitignore
├── .prettierrc.json
├── .gitattributes
├── extension-examples
├── dialog
│ ├── index.php
│ └── index.js
├── button-insert
│ ├── index.php
│ └── index.js
├── indent-with-tab
│ ├── index.php
│ ├── .editorconfig
│ ├── package.json
│ └── src
│ │ └── index.js
├── markdown-snippets
│ ├── index.php
│ └── index.js
├── custom-highlights
│ ├── screenshot.png
│ ├── index.css
│ └── index.php
└── custom-pagelink
│ └── index.js
├── src
├── components
│ ├── Utils
│ │ ├── dom.js
│ │ ├── strings.js
│ │ ├── complete-assign.js
│ │ ├── browser.js
│ │ ├── keymap.js
│ │ └── syntax.js
│ ├── Buttons
│ │ ├── Divider.js
│ │ ├── OrderedList.js
│ │ ├── Blockquote.js
│ │ ├── InlineCode.js
│ │ ├── Invisibles.js
│ │ ├── Highlight.js
│ │ ├── Strikethrough.js
│ │ ├── Footnote.js
│ │ ├── SpecialChars.js
│ │ ├── Button.js
│ │ ├── index.js
│ │ ├── StrongEmphasis.js
│ │ ├── Emphasis.js
│ │ ├── BulletList.js
│ │ ├── File.js
│ │ ├── HorizontalRule.js
│ │ ├── Headlines.js
│ │ └── Link.js
│ ├── Extensions
│ │ ├── DropCursor.js
│ │ ├── Highlight.js
│ │ ├── Autocomplete.js
│ │ ├── Invisibles.js
│ │ ├── PasteUrls.js
│ │ ├── FirefoxBlurFix.js
│ │ ├── LineStyles.js
│ │ ├── TaskLists.js
│ │ ├── ImagePreview.js
│ │ ├── FilePicker.js
│ │ ├── Theme.js
│ │ ├── URLs.js
│ │ └── KirbytextLanguage.js
│ ├── InlineFormats.js
│ ├── BlockFormats.js
│ ├── Emitter.js
│ ├── Extension.js
│ ├── MarkdownField.vue
│ ├── Extensions.js
│ ├── MarkdownBlock.vue
│ ├── MarkdownToolbar.vue
│ ├── Editor.js
│ └── MarkdownInput.vue
├── variables.css
├── index.js
└── syntax.css
├── vendor
├── composer
│ ├── autoload_namespaces.php
│ ├── autoload_psr4.php
│ ├── autoload_classmap.php
│ ├── platform_check.php
│ ├── LICENSE
│ ├── autoload_static.php
│ ├── installed.php
│ ├── autoload_real.php
│ └── installed.json
├── getkirby
│ └── composer-installer
│ │ ├── composer.json
│ │ ├── src
│ │ └── ComposerInstaller
│ │ │ ├── Plugin.php
│ │ │ ├── CmsInstaller.php
│ │ │ ├── Installer.php
│ │ │ └── PluginInstaller.php
│ │ └── readme.md
└── autoload.php
├── blueprints
└── blocks
│ └── markdown.yml
├── psalm.xml.dist
├── .vscode
└── settings.json
├── eslintrc.js
├── index.php
├── .editorconfig
├── .eslintrc.js
├── phpunit.xml.dist
├── .github
└── FUNDING.yml
├── LICENSE
├── translations
├── de.php
├── en.php
└── fr.php
├── composer.json
├── package.json
├── .php-cs-fixer.dist.php
├── phpmd.xml.dist
├── fields
└── markdown.php
└── index.css
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .DS_Store
3 | .php-cs-fixer.cache
4 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "none",
3 | "useTabs": true
4 | }
5 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | /index.js binary
2 | /index.css binary
3 | /package-lock.json binary
4 |
--------------------------------------------------------------------------------
/extension-examples/dialog/index.php:
--------------------------------------------------------------------------------
1 | array($vendorDir . '/getkirby/composer-installer/src'),
10 | );
11 |
--------------------------------------------------------------------------------
/vendor/composer/autoload_classmap.php:
--------------------------------------------------------------------------------
1 | $vendorDir . '/composer/InstalledVersions.php',
10 | );
11 |
--------------------------------------------------------------------------------
/extension-examples/indent-with-tab/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "scripts": {
3 | "dev": "kirbyup src/index.js --watch",
4 | "build": "kirbyup src/index.js"
5 | },
6 | "devDependencies": {
7 | "kirbyup": "^0.23.0"
8 | },
9 | "dependencies": {
10 | "@codemirror/commands": "^0.19.8"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/blueprints/blocks/markdown.yml:
--------------------------------------------------------------------------------
1 | name: field.blocks.markdown.name
2 | icon: markdown
3 | preview: markdown
4 | wysiwyg: true
5 | fields:
6 | text:
7 | label: field.blocks.markdown.label
8 | placeholder: field.blocks.markdown.placeholder
9 | type: markdown
10 | buttons: true
11 | font: monospace
12 | spellcheck: false
13 |
--------------------------------------------------------------------------------
/psalm.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/extension-examples/custom-highlights/index.css:
--------------------------------------------------------------------------------
1 | .my-text-variable {
2 | background: rgb(175, 57, 57, .1);
3 | color: rgb(175, 57, 57);
4 | padding: 2px;
5 | margin: -2px;
6 | border-radius: 2px;
7 | }
8 |
9 | .my-mark-highlight {
10 | background: rgba(255, 230, 0, .5);
11 | border-radius: .125em;
12 | color: var(--color-text) !important;
13 | margin: -2px;
14 | padding: 2px;
15 | }
16 |
17 | .my-mark-highlight * {
18 | color: currentColor !important;
19 | }
20 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "[markdown]": {
3 | "editor.wordWrap": "on",
4 | "editor.quickSuggestions": {
5 | "comments": "off",
6 | "strings": "off",
7 | "other": "off"
8 | }
9 | },
10 | "search.exclude": {
11 | "**/package-lock.json": true,
12 | "**/composer.lock": true,
13 | "**/yarn.lock": true,
14 | "**/vendor/**": true
15 | },
16 | "css.validate": false,
17 | "editor.codeActionsOnSave": {
18 | "source.fixAll": true
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ["eslint:recommended", "plugin:vue/recommended", "prettier"],
3 | rules: {
4 | "vue/attributes-order": "error",
5 | "vue/component-definition-name-casing": "off",
6 | "vue/html-closing-bracket-newline": [
7 | "error",
8 | {
9 | singleline: "never",
10 | multiline: "always"
11 | }
12 | ],
13 | "vue/multi-word-component-names": "off",
14 | "vue/require-default-prop": "off",
15 | "vue/require-prop-types": "error"
16 | }
17 | };
18 |
--------------------------------------------------------------------------------
/index.php:
--------------------------------------------------------------------------------
1 | [
7 | 'blocks/markdown' => __DIR__ . '/blueprints/blocks/markdown.yml',
8 | ],
9 | 'fields' => [
10 | 'markdown' => require __DIR__ . '/fields/markdown.php',
11 | ],
12 | 'translations' => [
13 | 'en' => require __DIR__ . '/translations/en.php',
14 | 'fr' => require __DIR__ . '/translations/fr.php',
15 | 'de' => require __DIR__ . '/translations/de.php',
16 | ],
17 | ]);
18 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # This file is for unifying the coding style for different editors and IDEs
2 | # editorconfig.org
3 |
4 | # PHP PSR-12 Coding Standards
5 | # https://www.php-fig.org/psr/psr-12/
6 |
7 | root = true
8 |
9 | [*]
10 | charset = utf-8
11 | end_of_line = lf
12 | indent_style = tab
13 | indent_size = 2
14 | trim_trailing_whitespace = true
15 |
16 | [*.php]
17 | indent_size = 4
18 | insert_final_newline = true
19 |
20 | [*.yml]
21 | indent_style = space
22 |
23 | [*.md]
24 | trim_trailing_whitespace = false
25 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /*eslint-env node*/
2 |
3 | module.exports = {
4 | extends: ["eslint:recommended", "plugin:vue/recommended", "prettier"],
5 | rules: {
6 | "vue/attributes-order": "error",
7 | "vue/component-definition-name-casing": "off",
8 | "vue/html-closing-bracket-newline": [
9 | "error",
10 | {
11 | singleline: "never",
12 | multiline: "always"
13 | }
14 | ],
15 | "vue/multi-word-component-names": "off",
16 | "vue/require-default-prop": "off",
17 | "vue/require-prop-types": "error"
18 | }
19 | };
20 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
12 |
13 |
14 |
15 | ./src
16 |
17 |
18 |
19 |
20 |
21 | ./tests/
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/src/components/Utils/strings.js:
--------------------------------------------------------------------------------
1 | export function ltrim(str) {
2 | return str.replace(/^[\s\uFEFF\xA0]+/g, "");
3 | }
4 |
5 | export function rtrim(str) {
6 | return str.replace(/[\s\uFEFF\xA0]+$/g, "");
7 | }
8 |
9 | export function isEmail(str) {
10 | // https://emailregex.com/
11 | return str.match(
12 | /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
13 | );
14 | }
15 |
16 | export function isURL(str) {
17 | // starts with http:// | https:// and doesn't contain any space
18 | return str.match(/^https?:\/\//) && !str.match(/\s/);
19 | }
20 |
21 | export default {
22 | isEmail,
23 | isURL,
24 | ltrim,
25 | rtrim
26 | };
27 |
--------------------------------------------------------------------------------
/src/components/Buttons/OrderedList.js:
--------------------------------------------------------------------------------
1 | import Button from "./Button.js";
2 |
3 | export default class OrderedList extends Button {
4 | get button() {
5 | return {
6 | icon: "list-numbers",
7 | label:
8 | this.input.$t("toolbar.button.ol") + this.formatKeyName(this.keys()[0]),
9 | command: this.command
10 | };
11 | }
12 |
13 | get command() {
14 | return () => this.editor.toggleBlockFormat(this.token);
15 | }
16 |
17 | keys() {
18 | return [
19 | {
20 | mac: "Ctrl-Alt-o",
21 | key: "Alt-Shift-o",
22 | run: this.command,
23 | preventDefault: true
24 | }
25 | ];
26 | }
27 |
28 | get name() {
29 | return "ol";
30 | }
31 |
32 | get token() {
33 | return "OrderedList";
34 | }
35 |
36 | get tokenType() {
37 | return "block";
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [fabianmichael]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
14 |
--------------------------------------------------------------------------------
/src/components/Extensions/DropCursor.js:
--------------------------------------------------------------------------------
1 | import { ViewPlugin } from "@codemirror/view";
2 | import { throttle } from "underscore";
3 |
4 | import Extension from "../Extension.js";
5 |
6 | // Updateing the cursor for every dragOver event is too costly,
7 | // update only 20 times per second max.
8 | const onDragOver = throttle((e, view) => {
9 | const pos = view.posAtCoords({ x: e.clientX, y: e.clientY });
10 | view.dispatch({ selection: { anchor: pos } });
11 | }, 50);
12 |
13 | export default class DropCursor extends Extension {
14 | plugins() {
15 | return [
16 | ViewPlugin.define(() => {}, {
17 | // eslint-disable-line no-unused-vars
18 | eventHandlers: {
19 | dragover: onDragOver
20 | }
21 | })
22 | ];
23 | }
24 |
25 | get type() {
26 | return "theme";
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/Buttons/Blockquote.js:
--------------------------------------------------------------------------------
1 | import Button from "./Button.js";
2 |
3 | export default class BlockQuote extends Button {
4 | get button() {
5 | return {
6 | icon: "quote",
7 | label:
8 | this.input.$t("markdown.toolbar.button.blockquote") +
9 | this.formatKeyName(this.keys()[0]),
10 | command: this.command
11 | };
12 | }
13 |
14 | get command() {
15 | return () => this.editor.toggleBlockFormat(this.token);
16 | }
17 |
18 | keys() {
19 | return [
20 | {
21 | mac: "Ctrl-Alt-q",
22 | key: "Alt-Shift-q",
23 | run: this.command,
24 | preventDefault: true
25 | }
26 | ];
27 | }
28 |
29 | get name() {
30 | return "blockquote";
31 | }
32 |
33 | get token() {
34 | return "Blockquote";
35 | }
36 |
37 | get tokenType() {
38 | return "block";
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/Buttons/InlineCode.js:
--------------------------------------------------------------------------------
1 | import Button from "./Button.js";
2 |
3 | export default class InlineCode extends Button {
4 | get button() {
5 | return {
6 | icon: "code",
7 | label:
8 | this.input.$t("toolbar.button.code") +
9 | this.formatKeyName(this.keys()[0]),
10 | command: this.command
11 | };
12 | }
13 |
14 | get command() {
15 | return () =>
16 | !this.isDisabled() && this.editor.toggleInlineFormat(this.token);
17 | }
18 |
19 | keys() {
20 | return [
21 | {
22 | mac: "Ctrl-Alt-x",
23 | key: "Alt-Shift-x",
24 | run: this.command,
25 | preventDefault: true
26 | }
27 | ];
28 | }
29 |
30 | get name() {
31 | return "code";
32 | }
33 |
34 | get token() {
35 | return "InlineCode";
36 | }
37 |
38 | get tokenType() {
39 | return "inline";
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/Utils/complete-assign.js:
--------------------------------------------------------------------------------
1 | // Source: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign#copying_accessors
2 | export default function completeAssign(target, ...sources) {
3 | sources.forEach((source) => {
4 | let descriptors = Object.keys(source).reduce((descriptors, key) => {
5 | descriptors[key] = Object.getOwnPropertyDescriptor(source, key);
6 | return descriptors;
7 | }, {});
8 |
9 | // By default, Object.assign copies enumerable Symbols, too
10 | Object.getOwnPropertySymbols(source).forEach((sym) => {
11 | let descriptor = Object.getOwnPropertyDescriptor(source, sym);
12 | if (descriptor.enumerable) {
13 | descriptors[sym] = descriptor;
14 | }
15 | });
16 | Object.defineProperties(target, descriptors);
17 | });
18 | return target;
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/Buttons/Invisibles.js:
--------------------------------------------------------------------------------
1 | import Button from "./Button.js";
2 |
3 | export default class Invisibles extends Button {
4 | get button() {
5 | return {
6 | align: "right",
7 | icon: "preview",
8 | label:
9 | this.input.$t("markdown.toolbar.button.invisibles") +
10 | this.formatKeyName(this.keys()[0]),
11 | command: this.command
12 | };
13 | }
14 |
15 | get command() {
16 | return () => this.editor.toggleInvisibles();
17 | }
18 |
19 | keys() {
20 | return [
21 | {
22 | mac: "Ctrl-Alt-i",
23 | key: "Alt-Shift-i",
24 | run: this.command,
25 | preventDefault: true
26 | }
27 | ];
28 | }
29 |
30 | get name() {
31 | return "invisibles";
32 | }
33 |
34 | get tokenType() {
35 | return "setting";
36 | }
37 |
38 | get isDisabled() {
39 | return () => false;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/Buttons/Highlight.js:
--------------------------------------------------------------------------------
1 | import Button from "./Button.js";
2 |
3 | export default class Highlight extends Button {
4 | get button() {
5 | return {
6 | icon: "highlight",
7 | label:
8 | this.input.$t("toolbar.button.highlight") +
9 | this.formatKeyName(this.keys()[0]),
10 | command: this.command
11 | };
12 | }
13 |
14 | get command() {
15 | return () =>
16 | !this.isDisabled() && this.editor.toggleInlineFormat(this.token);
17 | }
18 |
19 | keys() {
20 | return [
21 | {
22 | mac: "Ctrl-Alt-y",
23 | key: "Alt-Shift-y",
24 | run: this.command,
25 | preventDefault: true
26 | }
27 | ];
28 | }
29 |
30 | get name() {
31 | return "highlight";
32 | }
33 |
34 | get token() {
35 | return "Highlight";
36 | }
37 |
38 | get tokenType() {
39 | return "inline";
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/vendor/getkirby/composer-installer/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "getkirby/composer-installer",
3 | "description": "Kirby's custom Composer installer for the Kirby CMS and for Kirby plugins",
4 | "type": "composer-plugin",
5 | "license": "MIT",
6 | "homepage": "https://getkirby.com",
7 | "require": {
8 | "composer-plugin-api": "^1.0 || ^2.0"
9 | },
10 | "require-dev": {
11 | "composer/composer": "^1.8 || ^2.0"
12 | },
13 | "autoload": {
14 | "psr-4": {
15 | "Kirby\\": "src/"
16 | }
17 | },
18 | "autoload-dev": {
19 | "psr-4": {
20 | "Kirby\\": "tests/"
21 | }
22 | },
23 | "scripts": {
24 | "fix": "php-cs-fixer fix --config .php_cs",
25 | "test": "--stderr --coverage-html=tests/coverage"
26 | },
27 | "extra": {
28 | "class": "Kirby\\ComposerInstaller\\Plugin"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/Buttons/Strikethrough.js:
--------------------------------------------------------------------------------
1 | import Button from "./Button.js";
2 |
3 | export default class Strikethrough extends Button {
4 | get button() {
5 | return {
6 | icon: "strikethrough",
7 | label:
8 | this.input.$t("markdown.toolbar.button.strikethrough") +
9 | this.formatKeyName(this.keys()[0]),
10 | command: this.command
11 | };
12 | }
13 |
14 | get command() {
15 | return () =>
16 | !this.isDisabled() && this.editor.toggleInlineFormat(this.token);
17 | }
18 |
19 | keys() {
20 | return [
21 | {
22 | mac: "Ctrl-Alt-d",
23 | key: "Alt-Shift-d",
24 | run: this.command,
25 | preventDefault: true
26 | }
27 | ];
28 | }
29 |
30 | get name() {
31 | return "strikethrough";
32 | }
33 |
34 | get token() {
35 | return "Strikethrough";
36 | }
37 |
38 | get tokenType() {
39 | return "inline";
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/vendor/autoload.php:
--------------------------------------------------------------------------------
1 | {
4 | result[def.token] = result[def.token]
5 | ? Object.assign(result[def.token], def)
6 | : def;
7 | return result;
8 | }, {});
9 |
10 | this.markTokens = defs.reduce((result, def) => {
11 | if (result.includes(def.markToken)) return result;
12 | return [...result, def.markToken];
13 | }, []);
14 | }
15 |
16 | get(type) {
17 | return this.defs[type];
18 | }
19 |
20 | exists(type) {
21 | return typeof this.defs[type] !== "undefined";
22 | }
23 |
24 | hasMark(type) {
25 | if (!this.exists(type)) return false;
26 | return typeof this.get(type).mark !== "undefined";
27 | }
28 |
29 | mark(type) {
30 | if (!this.exists(type)) return null;
31 | return this.get(type).mark;
32 | }
33 |
34 | markTokenExists(token) {
35 | return this.markTokens.includes(token);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/Buttons/Footnote.js:
--------------------------------------------------------------------------------
1 | import Button from "./Button.js";
2 | import { EditorSelection } from "@codemirror/state";
3 |
4 | export default class Footnote extends Button {
5 | get button() {
6 | return {
7 | icon: "footnote",
8 | label:
9 | this.input.$t("markdown.toolbar.button.footnote") +
10 | this.formatKeyName(this.keys()[0]),
11 | command: this.command
12 | };
13 | }
14 |
15 | keys() {
16 | return [
17 | {
18 | mac: "Ctrl-Alt-f",
19 | key: "Alt-Shift-f",
20 | run: this.command,
21 | preventDefault: true
22 | }
23 | ];
24 | }
25 |
26 | get command() {
27 | return () =>
28 | this.editor.dispatch(
29 | this.editor.state.changeByRange((range) => ({
30 | changes: [
31 | { from: range.from, insert: "[^" },
32 | { from: range.to, insert: "]" }
33 | ],
34 | range: EditorSelection.range(range.from + 2, range.to + 2)
35 | }))
36 | );
37 | }
38 |
39 | get name() {
40 | return "footnote";
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/components/Buttons/SpecialChars.js:
--------------------------------------------------------------------------------
1 | import Button from "./Button.js";
2 |
3 | export default class SpecialChars extends Button {
4 | get button() {
5 | return {
6 | icon: "special-chars",
7 | label: this.input.$t("toolbar.button.headings"),
8 | dropdown: [
9 | {
10 | label:
11 | "No-Break Space" +
12 | this.formatKeyName({ mac: "Alt-Space" }, "", ""),
13 | command: () => this.editor.insert("\u00a0")
14 | },
15 | {
16 | label: "Thin Space",
17 | command: () => this.editor.insert("\u2009")
18 | },
19 | {
20 | label: "Thin No-Break Space",
21 | command: () => this.editor.insert("\u202f")
22 | },
23 | {
24 | label: "Soft Hyphen",
25 | command: () => this.editor.insert("\u00ad")
26 | },
27 | {
28 | label: "Zero-Width Space",
29 | command: () => this.editor.insert("\u200b")
30 | }
31 | ]
32 | };
33 | }
34 |
35 | get isDisabled() {
36 | return () => false;
37 | }
38 |
39 | get name() {
40 | return "chars";
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/vendor/composer/platform_check.php:
--------------------------------------------------------------------------------
1 | = 80100)) {
8 | $issues[] = 'Your Composer dependencies require a PHP version ">= 8.1.0". You are running ' . PHP_VERSION . '.';
9 | }
10 |
11 | if ($issues) {
12 | if (!headers_sent()) {
13 | header('HTTP/1.1 500 Internal Server Error');
14 | }
15 | if (!ini_get('display_errors')) {
16 | if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
17 | fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL);
18 | } elseif (!headers_sent()) {
19 | echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
20 | }
21 | }
22 | trigger_error(
23 | 'Composer detected issues in your platform: ' . implode(' ', $issues),
24 | E_USER_ERROR
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/extension-examples/custom-highlights/index.php:
--------------------------------------------------------------------------------
1 | [
5 | /**
6 | * Simple highlight, using regex.
7 | */
8 | [
9 | 'name' => 'mark', // there can be multiple highlights with the same name
10 | 'regex' => '.*',
11 | 'flags' => 'i', // 'g' flag is added automatically
12 | 'class' => 'my-mark-highlight',
13 | ],
14 | /**
15 | * Advanced highliht
16 | */
17 | function () {
18 |
19 | // Array of known text variables, should be fetched from some
20 | // data source in a real pluign. Allows for validation, because
21 | // only known variables will be highlighted properly.
22 | $knownVariables = [
23 | 'disclaimer',
24 | 'support-me',
25 | 'copyright-info'
26 | ];
27 |
28 | return [
29 | 'name' => 'variables',
30 | 'regex' => '\{% (' . implode('|', $knownVariables) . ') \%}',
31 | 'class' => 'my-text-variable',
32 | ];
33 | },
34 | ]
35 | ]);
36 |
--------------------------------------------------------------------------------
/src/components/Extensions/Highlight.js:
--------------------------------------------------------------------------------
1 | import { ViewPlugin, MatchDecorator, Decoration } from "@codemirror/view";
2 | import Extension from "../Extension.js";
3 |
4 | export default class Highlight extends Extension {
5 | get defaults() {
6 | return {
7 | name: "highlight",
8 | regex: "",
9 | flags: "g",
10 | class: "cm-highlight"
11 | };
12 | }
13 |
14 | get type() {
15 | return "highlight";
16 | }
17 |
18 | plugins() {
19 | const deco = Decoration.mark({ class: this.options.class });
20 |
21 | let flags = this.options.flags || "";
22 | flags += flags.includes("g") ? "" : "g"; // ensure, that every regex has the global flag
23 |
24 | const decorator = new MatchDecorator({
25 | regexp: new RegExp(this.options.regex, flags),
26 | decoration: () => deco
27 | });
28 |
29 | return [
30 | ViewPlugin.define(
31 | (view) => ({
32 | decorations: decorator.createDeco(view),
33 | update(u) {
34 | this.decorations = decorator.updateDeco(u, this.decorations);
35 | }
36 | }),
37 | {
38 | decorations: (v) => v.decorations
39 | }
40 | )
41 | ];
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/Buttons/Button.js:
--------------------------------------------------------------------------------
1 | import Extension from "../Extension.js";
2 | import completeAssign from "../Utils/complete-assign.js";
3 |
4 | export default class Button extends Extension {
5 | constructor(options = {}) {
6 | super(options);
7 | }
8 |
9 | get button() {
10 | return null;
11 | }
12 |
13 | get dialog() {
14 | return null;
15 | }
16 |
17 | get token() {
18 | return null;
19 | }
20 |
21 | get tokenType() {
22 | return null;
23 | }
24 |
25 | get type() {
26 | return "button";
27 | }
28 |
29 | get isActive() {
30 | if (this.token !== null) {
31 | return () => this.editor.isActiveToken(this.token);
32 | }
33 |
34 | return () => false;
35 | }
36 |
37 | get isDisabled() {
38 | if (this.tokenType === "block") {
39 | return () => false;
40 | }
41 |
42 | return () =>
43 | this.editor.isActiveToken("Kirbytag", "FencedCode", "Link", "URL");
44 | }
45 |
46 | /**
47 | * Creates a custom extension from an object
48 | */
49 | static factory(definition) {
50 | const extension = new Button();
51 | completeAssign(extension, definition);
52 | return extension;
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/vendor/composer/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Copyright (c) Nils Adermann, Jordi Boggiano
3 |
4 | Permission is hereby granted, free of charge, to any person obtaining a copy
5 | of this software and associated documentation files (the "Software"), to deal
6 | in the Software without restriction, including without limitation the rights
7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | copies of the Software, and to permit persons to whom the Software is furnished
9 | to do so, subject to the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be included in all
12 | copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 | THE SOFTWARE.
21 |
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018-2021 Sylvain Julé and Fabian Michael and other people from
4 | the Kirby community
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 |
--------------------------------------------------------------------------------
/src/components/BlockFormats.js:
--------------------------------------------------------------------------------
1 | export default class InlineFormats {
2 | constructor(defs) {
3 | this.defs = defs.reduce((result, def) => {
4 | result[def.token] = result[def.token]
5 | ? Object.assign(result[def.token], def)
6 | : def;
7 | return result;
8 | }, {});
9 |
10 | this.markTokens = defs.reduce((result, def) => {
11 | if (result.includes(def.markToken)) return result;
12 | return [...result, def.markToken];
13 | }, []);
14 |
15 | this.blockTypes = Object.keys(this.defs);
16 | }
17 |
18 | get(type) {
19 | return this.defs[type];
20 | }
21 |
22 | exists(type) {
23 | return typeof this.defs[type] !== "undefined";
24 | }
25 |
26 | hasMark(type) {
27 | if (!this.exists(type)) return false;
28 | return typeof this.get(type).mark !== "undefined";
29 | }
30 |
31 | mark(type) {
32 | if (!this.exists(type)) return null;
33 | return this.get(type).mark;
34 | }
35 |
36 | markTokenExists(token) {
37 | return this.markTokens.includes(token);
38 | }
39 |
40 | get types() {
41 | return this.blockTypes;
42 | }
43 |
44 | render(type, n) {
45 | const format = this.get(type);
46 | return typeof format.render === "function"
47 | ? format.render(n)
48 | : format.render;
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/vendor/composer/autoload_static.php:
--------------------------------------------------------------------------------
1 |
11 | array (
12 | 'Kirby\\' => 6,
13 | ),
14 | );
15 |
16 | public static $prefixDirsPsr4 = array (
17 | 'Kirby\\' =>
18 | array (
19 | 0 => __DIR__ . '/..' . '/getkirby/composer-installer/src',
20 | ),
21 | );
22 |
23 | public static $classMap = array (
24 | 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
25 | );
26 |
27 | public static function getInitializer(ClassLoader $loader)
28 | {
29 | return \Closure::bind(function () use ($loader) {
30 | $loader->prefixLengthsPsr4 = ComposerStaticInitff94e748cd75a7842b6597550c9ec69d::$prefixLengthsPsr4;
31 | $loader->prefixDirsPsr4 = ComposerStaticInitff94e748cd75a7842b6597550c9ec69d::$prefixDirsPsr4;
32 | $loader->classMap = ComposerStaticInitff94e748cd75a7842b6597550c9ec69d::$classMap;
33 |
34 | }, null, ClassLoader::class);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/vendor/composer/installed.php:
--------------------------------------------------------------------------------
1 | array(
3 | 'name' => 'fabianmichael/kirby-markdown-field',
4 | 'pretty_version' => '3.0.0-alpha.2',
5 | 'version' => '3.0.0.0-alpha2',
6 | 'reference' => NULL,
7 | 'type' => 'kirby-plugin',
8 | 'install_path' => __DIR__ . '/../../',
9 | 'aliases' => array(),
10 | 'dev' => false,
11 | ),
12 | 'versions' => array(
13 | 'fabianmichael/kirby-markdown-field' => array(
14 | 'pretty_version' => '3.0.0-alpha.2',
15 | 'version' => '3.0.0.0-alpha2',
16 | 'reference' => NULL,
17 | 'type' => 'kirby-plugin',
18 | 'install_path' => __DIR__ . '/../../',
19 | 'aliases' => array(),
20 | 'dev_requirement' => false,
21 | ),
22 | 'getkirby/composer-installer' => array(
23 | 'pretty_version' => '1.2.1',
24 | 'version' => '1.2.1.0',
25 | 'reference' => 'c98ece30bfba45be7ce457e1102d1b169d922f3d',
26 | 'type' => 'composer-plugin',
27 | 'install_path' => __DIR__ . '/../getkirby/composer-installer',
28 | 'aliases' => array(),
29 | 'dev_requirement' => false,
30 | ),
31 | ),
32 | );
33 |
--------------------------------------------------------------------------------
/src/components/Buttons/index.js:
--------------------------------------------------------------------------------
1 | import Blockquote from "./Buttons/Blockquote.js";
2 | import BulletList from "./Buttons/BulletList.js";
3 | import Button from "./Buttons/Button.js";
4 | import Divider from "./Buttons/Divider.js";
5 | import Emphasis from "./Buttons/Emphasis.js";
6 | import File from "./Buttons/File.js";
7 | import Footnote from "./Buttons/Footnote.js";
8 | import Headlines from "./Buttons/Headlines.js";
9 | import HighlightButton from "./Buttons/Highlight.js";
10 | import HorizontalRule from "./Buttons/HorizontalRule.js";
11 | import InlineCode from "./Buttons/InlineCode.js";
12 | import Invisibles from "./Buttons/Invisibles.js";
13 | import Link from "./Buttons/Link.js";
14 | import OrderedList from "./Buttons/OrderedList.js";
15 | import SpecialChars from "./Buttons/SpecialChars.js";
16 | import Strikethrough from "./Buttons/Strikethrough.js";
17 | import StrongEmphasis from "./Buttons/StrongEmphasis.js";
18 | import Extension from "./Extension.js";
19 |
20 | export default {
21 | Blockquote,
22 | BulletList,
23 | Button,
24 | Divider,
25 | Emphasis,
26 | File,
27 | Footnote,
28 | Headlines,
29 | HighlightButton,
30 | HorizontalRule,
31 | InlineCode,
32 | Invisibles,
33 | Link,
34 | OrderedList,
35 | SpecialChars,
36 | Strikethrough,
37 | StrongEmphasis,
38 | Extension
39 | };
40 |
--------------------------------------------------------------------------------
/src/components/Emitter.js:
--------------------------------------------------------------------------------
1 | export default class Emitter {
2 | emit(event, ...args) {
3 | this._callbacks = this._callbacks || {};
4 | const callbacks = this._callbacks[event];
5 |
6 | if (callbacks) {
7 | callbacks.forEach((cb) => cb.apply(this, args));
8 | }
9 |
10 | return this;
11 | }
12 |
13 | /**
14 | * Remove event listener for given event.
15 | * If fn is not provided, all event listeners for that event will be removed.
16 | * If neither is provided, all event listeners will be removed.
17 | */
18 | off(event, fn) {
19 | if (!arguments.length) {
20 | this._callbacks = {};
21 | } else {
22 | // event listeners for the given event
23 | const callbacks = this._callbacks ? this._callbacks[event] : null;
24 | if (callbacks) {
25 | if (fn) {
26 | // remove specific handler
27 | this._callbacks[event] = callbacks.filter((cb) => cb !== fn);
28 | } else {
29 | // remove all handlers
30 | delete this._callbacks[event];
31 | }
32 | }
33 | }
34 |
35 | return this;
36 | }
37 |
38 | /**
39 | * Add an event listener for given event
40 | */
41 | on(event, fn) {
42 | this._callbacks = this._callbacks || {};
43 | this._callbacks[event] = this._callbacks[event] || [];
44 | this._callbacks[event].push(fn);
45 | return this;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/vendor/composer/autoload_real.php:
--------------------------------------------------------------------------------
1 | register(true);
35 |
36 | return $loader;
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/Buttons/StrongEmphasis.js:
--------------------------------------------------------------------------------
1 | import Button from "./Button.js";
2 |
3 | export default class StrongEmphasis extends Button {
4 | get button() {
5 | return {
6 | icon: "bold",
7 | label:
8 | this.input.$t("toolbar.button.bold") +
9 | this.formatKeyName(this.keys()[0]),
10 | command: this.command
11 | };
12 | }
13 |
14 | get command() {
15 | return () =>
16 | !this.isDisabled() && this.editor.toggleInlineFormat(this.token);
17 | }
18 |
19 | configure(options) {
20 | if (typeof options === "string") {
21 | options = { mark: options };
22 | }
23 |
24 | Button.prototype.configure.call(this, options);
25 |
26 | if (!["**", "__"].includes(this.options.mark)) {
27 | throw "Bold mark must be either `**` or `__`.";
28 | }
29 | }
30 |
31 | get defaults() {
32 | return {
33 | mark: "**"
34 | };
35 | }
36 |
37 | keys() {
38 | return [
39 | {
40 | key: "Mod-b",
41 | run: this.command,
42 | preventDefault: true
43 | }
44 | ];
45 | }
46 |
47 | get name() {
48 | return "bold";
49 | }
50 |
51 | get syntax() {
52 | return {
53 | token: this.token,
54 | type: this.tokenType,
55 | mark: this.options.mark
56 | };
57 | }
58 |
59 | get token() {
60 | return "StrongEmphasis";
61 | }
62 |
63 | get tokenType() {
64 | return "inline";
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/Extension.js:
--------------------------------------------------------------------------------
1 | import { formatKeyName } from "./Utils/keymap.js";
2 | import completeAssign from "./Utils/complete-assign.js";
3 |
4 | export default class Extension {
5 | constructor(options = {}) {
6 | this.configure(options);
7 | this._init = false;
8 | }
9 |
10 | configure(options = {}) {
11 | if (this._init) {
12 | throw "Extensions cannot be configured after they have been initalized.";
13 | }
14 |
15 | this.options = {
16 | ...this.defaults,
17 | ...options
18 | };
19 | }
20 |
21 | init() {
22 | return (this._init = true);
23 | }
24 |
25 | bindEditor(editor) {
26 | this.editor = editor;
27 | }
28 |
29 | bindInput(input) {
30 | this.input = input;
31 | }
32 |
33 | formatKeyName(name, before, after) {
34 | return formatKeyName(name, this.input.$t, before, after);
35 | }
36 |
37 | get name() {
38 | return null;
39 | }
40 |
41 | get type() {
42 | return "extension";
43 | }
44 |
45 | get defaults() {
46 | return {
47 | input: null
48 | };
49 | }
50 |
51 | plugins() {
52 | return [];
53 | }
54 |
55 | get syntax() {
56 | return null;
57 | }
58 |
59 | /**
60 | * Creates a custom extension from an object
61 | */
62 | static factory(definition) {
63 | const extension = new Extension();
64 | completeAssign(extension, definition);
65 | return extension;
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/components/Buttons/Emphasis.js:
--------------------------------------------------------------------------------
1 | import Button from "./Button.js";
2 |
3 | export default class Emphasis extends Button {
4 | get button() {
5 | return {
6 | icon: "italic",
7 | label:
8 | this.input.$t("toolbar.button.italic") +
9 | this.formatKeyName(this.keys()[0]),
10 | command: this.command
11 | };
12 | }
13 |
14 | get command() {
15 | return () =>
16 | !this.isDisabled() && this.editor.toggleInlineFormat(this.token);
17 | }
18 |
19 | configure(options) {
20 | if (typeof options === "string") {
21 | options = { mark: options };
22 | }
23 |
24 | Button.prototype.configure.call(this, options);
25 |
26 | if (!["*", "_"].includes(this.options.mark)) {
27 | throw "Italic mark must be either `*` or `_`.";
28 | }
29 | }
30 |
31 | get defaults() {
32 | return {
33 | mark: "*"
34 | };
35 | }
36 |
37 | keys() {
38 | return [
39 | {
40 | key: "Mod-i",
41 | run: this.command,
42 | preventDefault: true
43 | }
44 | ];
45 | }
46 |
47 | get name() {
48 | return "italic";
49 | }
50 |
51 | get syntax() {
52 | // Override default with configured syntax
53 | return {
54 | token: this.token,
55 | type: this.tokenType,
56 | mark: this.options.mark
57 | };
58 | }
59 |
60 | get token() {
61 | return "Emphasis";
62 | }
63 |
64 | get tokenType() {
65 | return "inline";
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/components/Buttons/BulletList.js:
--------------------------------------------------------------------------------
1 | import Button from "./Button.js";
2 |
3 | export default class BulletList extends Button {
4 | get button() {
5 | return {
6 | icon: "list-bullet",
7 | label:
8 | this.input.$t("toolbar.button.ul") + this.formatKeyName(this.keys()[0]),
9 | command: this.command
10 | };
11 | }
12 |
13 | get command() {
14 | return () => this.editor.toggleBlockFormat(this.token);
15 | }
16 |
17 | configure(options) {
18 | if (typeof options === "string") {
19 | options = { mark: options };
20 | }
21 |
22 | Button.prototype.configure.call(this, options);
23 |
24 | if (!["-", "*", "+"].includes(this.options.mark)) {
25 | throw "Bullet list mark must be either `-`, `*` or `+`.";
26 | }
27 | }
28 |
29 | get defaults() {
30 | return {
31 | mark: "-"
32 | };
33 | }
34 |
35 | keys() {
36 | return [
37 | {
38 | mac: "Ctrl-Alt-u",
39 | key: "Alt-Shift-u",
40 | run: this.command,
41 | preventDefault: true
42 | }
43 | ];
44 | }
45 |
46 | get name() {
47 | return "ul";
48 | }
49 |
50 | get syntax() {
51 | // Override default with configured syntax
52 | return {
53 | token: this.token,
54 | type: this.tokenType,
55 | render: this.options.mark + " "
56 | };
57 | }
58 |
59 | get token() {
60 | return "BulletList";
61 | }
62 |
63 | get tokenType() {
64 | return "block";
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/extension-examples/button-insert/index.js:
--------------------------------------------------------------------------------
1 | (function() {
2 |
3 | // Ensure, that the global `markdownEditorButtons` variable exists, otherwise
4 | // declare it. It is just a plain array, that gets read whenever the field is use.
5 | window.markdownEditorButtons = window.markdownEditorButtons || [];
6 |
7 | // Pass the plugin definition to the buttons array
8 | window.markdownEditorButtons.push({
9 | /**
10 | * The button definition. This is a simple one, buttons can also provide
11 | * fancy things, like a dropdown menu.
12 | */
13 | get button() {
14 | return {
15 | icon: "smile",
16 | label: "Smile",
17 | command: this.command,
18 | };
19 | },
20 | /**
21 | * What the button is actually supposed to do, when clicked.
22 | */
23 | get command() {
24 | return () => this.editor.insert(":-)");
25 | },
26 |
27 | /**
28 | * Must be a unique identifier. Use the `toolbar` field property in your blueprints,
29 | * to add this button to a Markdown field’s toolbar.
30 | */
31 | get name() {
32 | return "smile";
33 | },
34 |
35 | /**
36 | * Leave out this method to disable the button, when the cursor is inside of a
37 | * Kirbytag, fenced code or a Markdown link.
38 | */
39 | get isDisabled() {
40 | return () => false; // It’s always time to smile
41 | },
42 | });
43 |
44 | })();
45 |
--------------------------------------------------------------------------------
/src/components/Extensions/Autocomplete.js:
--------------------------------------------------------------------------------
1 | // Current version of CodeMirror does not support proper positioning of the
2 | // tooltip dialog within Kirby’s panel. will save this for later.
3 |
4 | // import {CompletionSource, autocompletion, CompletionContext, startCompletion,
5 | // currentCompletions, completionStatus, completeFromList } from "@codemirror/autocomplete"
6 |
7 | // export default function autocomplete() {
8 | // function from(list) {
9 | // return (cx) => { /* cx = completitionContext */
10 | // let word = cx.matchBefore(/\(\w*$/)
11 | // console.log("word", word, cx);
12 | // if (!word && !cx.explicit) {
13 | // console.log("no word")
14 | // return null;
15 | // }
16 | // return {
17 | // from: word ? word.from : cx.pos,
18 | // options: list.split(" ").map((word) => ({
19 | // label: `(${word}: …)`,
20 | // _insert: `(${word}: )`,
21 | // apply: (view, completition, from, to) => {
22 | // console.log("apply");
23 | // view.dispatch({
24 | // changes: {from, to, insert: completition._insert},
25 | // selection: { anchor: from + completition._insert.length - 1 }
26 | // });
27 | // }
28 | // })),
29 | // // span: /\w*/,
30 | // }
31 | // }
32 | // }
33 |
34 | // return autocompletion({override: [from("link image")]})
35 | // }
36 |
--------------------------------------------------------------------------------
/translations/de.php:
--------------------------------------------------------------------------------
1 | 'In neuem Tab öffnen?',
5 | 'markdown.key.alt' => 'Alt',
6 | 'markdown.key.ctrl' => 'Strg',
7 | 'markdown.key.meta' => 'Meta',
8 | 'markdown.key.shift' => 'Shift',
9 | 'markdown.key.space' => 'Leertaste',
10 | 'markdown.no' => 'Nein',
11 | 'markdown.toolbar.button.blockquote' => 'Zitat',
12 | 'markdown.toolbar.button.file' => 'Datei-Download einfügen',
13 | 'markdown.toolbar.button.footnote' => 'Fußnote',
14 | 'markdown.toolbar.button.heading.1' => 'Überschrift 1',
15 | 'markdown.toolbar.button.heading.2' => 'Überschrift 2',
16 | 'markdown.toolbar.button.heading.3' => 'Überschrift 3',
17 | 'markdown.toolbar.button.heading.4' => 'Überschrift 4',
18 | 'markdown.toolbar.button.heading.5' => 'Überschrift 5',
19 | 'markdown.toolbar.button.heading.6' => 'Überschrift 6',
20 | 'markdown.toolbar.button.hr' => 'Trennlinie',
21 | 'markdown.toolbar.button.image' => 'Bild einfügen',
22 | 'markdown.toolbar.button.invisibles' => 'Weißraum anzeigen',
23 | 'markdown.toolbar.button.pagelink' => 'Link zu einer Seite der Website',
24 | 'markdown.toolbar.button.strikethrough' => 'Durchgestrichener Text',
25 | 'markdown.yes' => 'Ja',
26 | ];
27 |
--------------------------------------------------------------------------------
/extension-examples/indent-with-tab/src/index.js:
--------------------------------------------------------------------------------
1 | import {indentWithTab} from "@codemirror/commands"
2 |
3 | (function() {
4 |
5 | // Ensure, that the global `markdownEditorExtensions` variable exists, otherwise
6 | // declare it. It is just a plain array, that gets read whenever the field is use.
7 | window.markdownEditorExtensions = window.markdownEditorExtensions || [];
8 |
9 | // Pass the plugin definition to the extensions array
10 | window.markdownEditorExtensions.push({
11 | keys() {
12 | // Any extension can register provide custom keyboard shortcuts
13 | // for the editor. This example extension add changes the tab key’s
14 | // behavior from focussing the next field to indenting text.
15 | // See https://codemirror.net/6/examples/tab/
16 | return [indentWithTab];
17 | },
18 |
19 | get type() {
20 | // Must return a string. User-defined extensions are loaded in the following order:
21 | // 1. keymap (use the key() method for providing a keymap. Keymaps are always generated first, regardless of extension type)
22 | // 2. language (syntax highlighting)
23 | // 3. highlight (additional highlighting, including use highlights)
24 | // 4. theme (general theming of the editor input)
25 | // 5. extension (generic extensions, can be anything)
26 | return "extension";
27 | },
28 |
29 | plugins() {
30 | // An array of CodeMirror plugins provided by this extension
31 | return [];
32 | }
33 | });
34 |
35 | })();
36 |
--------------------------------------------------------------------------------
/translations/en.php:
--------------------------------------------------------------------------------
1 | 'Open in a new tab?',
5 | 'markdown.key.alt' => 'Alt',
6 | 'markdown.key.ctrl' => 'Ctrl',
7 | 'markdown.key.meta' => 'Meta',
8 | 'markdown.key.shift' => 'Shift',
9 | 'markdown.key.space' => 'Space',
10 | 'markdown.linktype' => 'Link type',
11 | 'markdown.no' => 'No',
12 | 'markdown.toolbar.button.blockquote' => 'Blockquote',
13 | 'markdown.toolbar.button.file' => 'Insert a downloadable file',
14 | 'markdown.toolbar.button.footnote' => 'Footnote',
15 | 'markdown.toolbar.button.heading.1' => 'Heading 1',
16 | 'markdown.toolbar.button.heading.2' => 'Heading 2',
17 | 'markdown.toolbar.button.heading.3' => 'Heading 3',
18 | 'markdown.toolbar.button.heading.4' => 'Heading 4',
19 | 'markdown.toolbar.button.heading.5' => 'Heading 5',
20 | 'markdown.toolbar.button.heading.6' => 'Heading 6',
21 | 'markdown.toolbar.button.hr' => 'Horizontal rule',
22 | 'markdown.toolbar.button.image' => 'Insert an image',
23 | 'markdown.toolbar.button.invisibles' => 'Show hidden characters',
24 | 'markdown.toolbar.button.pagelink' => 'Link to a page of the website',
25 | 'markdown.toolbar.button.strikethrough' => 'Strikethrough',
26 | 'markdown.yes' => 'Yes',
27 | ];
28 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fabianmichael/kirby-markdown-field",
3 | "description": "Super-sophisticated markdown editor for Kirby 4",
4 | "homepage": "https://github.com/fabianmichael/kirby-markdown-field",
5 | "type": "kirby-plugin",
6 | "version": "3.0.0-alpha.2",
7 | "license": "MIT",
8 | "authors": [
9 | {
10 | "name": "Sylvain Julé",
11 | "email": "contact@sylvain-jule.fr"
12 | },
13 | {
14 | "name": "Fabian Michael",
15 | "email": "hallo@fabianmichael.de"
16 | }
17 | ],
18 | "require-dev": {
19 | "friendsofphp/php-cs-fixer": "^3.13",
20 | "phpunit/phpunit": "^9",
21 | "phpmd/phpmd" : "@stable",
22 | "vimeo/psalm": "^5.1"
23 | },
24 | "require": {
25 | "php": ">=8.1.0",
26 | "getkirby/composer-installer": "^1.2"
27 | },
28 | "extra": {
29 | "kirby-cms-path": false,
30 | "installer-name": "markdown-field"
31 | },
32 | "minimum-stability": "RC",
33 | "scripts": {
34 | "linter": "vendor/bin/php-cs-fixer fix --dry-run --diff",
35 | "linter:fix": "vendor/bin/php-cs-fixer fix --diff",
36 | "test": "phpunit --stderr",
37 | "analyze": [
38 | "@analyze:composer",
39 | "@analyze:psalm",
40 | "@analyze:phpmd"
41 | ],
42 | "analyze:composer": "composer validate --strict --no-check-version --no-check-all",
43 | "analyze:psalm": "psalm",
44 | "analyze:phpmd": "phpmd . ansi phpmd.xml.dist --exclude 'tests/*,vendor/*'"
45 | },
46 | "config": {
47 | "allow-plugins": {
48 | "getkirby/composer-installer": true
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/translations/fr.php:
--------------------------------------------------------------------------------
1 | 'Ouvrir dans un nouvel onglet ?',
5 | 'markdown.key.alt' => 'Alt',
6 | 'markdown.key.ctrl' => 'Strg',
7 | 'markdown.key.meta' => 'Meta',
8 | 'markdown.key.shift' => 'Shift',
9 | 'markdown.key.space' => 'Leertaste',
10 | 'markdown.no' => 'No',
11 | 'markdown.toolbar.button.blockquote' => 'Citation',
12 | 'markdown.toolbar.button.file' => 'Insérer un fichier téléchargeable',
13 | 'markdown.toolbar.button.footnote' => 'Note de bas de page',
14 | 'markdown.toolbar.button.heading.1' => 'Titre 1',
15 | 'markdown.toolbar.button.heading.2' => 'Titre 2',
16 | 'markdown.toolbar.button.heading.3' => 'Titre 3',
17 | 'markdown.toolbar.button.heading.4' => 'Titre 4',
18 | 'markdown.toolbar.button.heading.5' => 'Titre 5',
19 | 'markdown.toolbar.button.heading.6' => 'Titre 6',
20 | 'markdown.toolbar.button.hr' => 'Barre de séparation',
21 | 'markdown.toolbar.button.hr' => 'Séparateur horizontal',
22 | 'markdown.toolbar.button.image' => 'Insérer une image',
23 | 'markdown.toolbar.button.invisibles' => 'Afficher les caractères masqués',
24 | 'markdown.toolbar.button.pagelink' => 'Lien vers une page du site',
25 | 'markdown.toolbar.button.strikethrough' => 'Texte barré',
26 | 'markdown.yes' => 'Yes',
27 | ];
28 |
--------------------------------------------------------------------------------
/src/components/Buttons/File.js:
--------------------------------------------------------------------------------
1 | import Button from "./Button.js";
2 |
3 | export default class File extends Button {
4 | get button() {
5 | const button = {
6 | icon: "attachment",
7 | label: this.input.$t("toolbar.button.file")
8 | };
9 |
10 | if (this.input.uploads) {
11 | return {
12 | ...button,
13 | dropdown: [
14 | {
15 | label: this.input.$t("toolbar.button.file.select"),
16 | icon: "check",
17 | command: this.openSelectDialog
18 | },
19 | {
20 | label: this.input.$t("toolbar.button.file.upload"),
21 | icon: "upload",
22 | command: () => this.input.upload()
23 | }
24 | ]
25 | };
26 | } else {
27 | return {
28 | ...button,
29 | command: this.openSelectDialog
30 | };
31 | }
32 | }
33 |
34 | get openSelectDialog() {
35 | return () => this.input.file();
36 | }
37 |
38 | get command() {
39 | return (selected) => {
40 | if (this.isDisabled()) {
41 | return;
42 | }
43 |
44 | if (!selected || !selected.length) {
45 | return;
46 | }
47 |
48 | const selection = this.editor.getSelection();
49 |
50 | if (selected.length === 1 && selection.length > 0) {
51 | // only if one file was selected, use selected text to as
52 | // label for the link.
53 | const file = selected[0];
54 | this.editor.insert(`(file: ${file.filename} text: ${selection})`);
55 | } else {
56 | this.editor.insert(selected.map((file) => file.dragText).join("\n\n"));
57 | }
58 | };
59 | }
60 |
61 | get name() {
62 | return "file";
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "kirby-markdown-field",
3 | "description": "Enhanced markdown editor for Kirby 3",
4 | "main": "index.js",
5 | "author": "Kirby Community",
6 | "license": "MIT",
7 | "repository": {
8 | "type": "git",
9 | "url": "git@github.com:sylvainjule/kirby-markdown-field.git"
10 | },
11 | "scripts": {
12 | "dev": "kirbyup src/index.js --watch",
13 | "build": "kirbyup src/index.js",
14 | "lint": "eslint \"src/**/*.{js,vue}\"",
15 | "lint:fix": "npm run lint -- --fix",
16 | "format": "prettier --write \"src/**/*.{js,vue}\""
17 | },
18 | "devDependencies": {
19 | "kirbyup": "^2.0.1",
20 | "eslint": "^8.52.0",
21 | "eslint-config-prettier": "^9.0.0",
22 | "eslint-plugin-vue": "^9.18.1",
23 | "prettier": "^3.1.0"
24 | },
25 | "dependencies": {
26 | "@codemirror/commands": "^6.1.1",
27 | "@codemirror/lang-markdown": "^6.0.1",
28 | "@codemirror/language": "^6.2.1",
29 | "@codemirror/search": "^6.2.1",
30 | "@codemirror/state": "^6.1.2",
31 | "@codemirror/view": "^6.3.0",
32 | "@lezer/highlight": "^1.1.1",
33 | "underscore": "^1.13.6"
34 | },
35 | "browserslist": [
36 | "last 2 Android versions",
37 | "last 2 Chrome versions",
38 | "last 2 ChromeAndroid versions",
39 | "last 2 Edge versions",
40 | "last 2 Firefox versions",
41 | "last 2 FirefoxAndroid versions",
42 | "last 2 iOS versions",
43 | "last 2 KaiOS versions",
44 | "last 2 Safari versions",
45 | "last 2 Samsung versions",
46 | "last 2 Opera versions",
47 | "last 2 OperaMobile versions",
48 | "last 2 UCAndroid versions"
49 | ]
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/Utils/browser.js:
--------------------------------------------------------------------------------
1 | // Source: https://github.com/codemirror/view/blob/main/src/browser.ts
2 | // Part of CodeMirror, released under the MIT license.
3 |
4 | let [nav, doc] =
5 | typeof navigator != "undefined"
6 | ? [navigator, document]
7 | : [
8 | { userAgent: "", vendor: "", platform: "" },
9 | { documentElement: { style: {} } }
10 | ];
11 |
12 | const ie_edge = /Edge\/(\d+)/.exec(nav.userAgent);
13 | const ie_upto10 = /MSIE \d/.test(nav.userAgent);
14 | const ie_11up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(nav.userAgent);
15 | const ie = !!(ie_upto10 || ie_11up || ie_edge);
16 | const gecko = !ie && /gecko\/(\d+)/i.test(nav.userAgent);
17 | const chrome = !ie && /Chrome\/(\d+)/.exec(nav.userAgent);
18 | const webkit = "webkitFontSmoothing" in doc.documentElement.style;
19 | const safari = !ie && /Apple Computer/.test(nav.vendor);
20 |
21 | export default {
22 | mac: /Mac/.test(nav.platform),
23 | ie,
24 | ie_version: ie_upto10
25 | ? doc.documentMode || 6
26 | : ie_11up
27 | ? +ie_11up[1]
28 | : ie_edge
29 | ? +ie_edge[1]
30 | : 0,
31 | gecko,
32 | gecko_version: gecko
33 | ? +(/Firefox\/(\d+)/.exec(nav.userAgent) || [0, 0])[1]
34 | : 0,
35 | chrome: !!chrome,
36 | chrome_version: chrome ? +chrome[1] : 0,
37 | ios: safari && (/Mobile\/\w+/.test(nav.userAgent) || nav.maxTouchPoints > 2),
38 | android: /Android\b/.test(nav.userAgent),
39 | webkit,
40 | safari,
41 | webkit_version: webkit
42 | ? +(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent) || [0, 0])[1]
43 | : 0,
44 | tabSize:
45 | doc.documentElement.style.tabSize != null ? "tab-size" : "-moz-tab-size"
46 | };
47 |
--------------------------------------------------------------------------------
/src/components/Extensions/Invisibles.js:
--------------------------------------------------------------------------------
1 | import { ViewPlugin, MatchDecorator, Decoration } from "@codemirror/view";
2 | import Extension from "../Extension.js";
3 |
4 | /**
5 | * CodeMirror’s highlight specialchars plugin breaks spellchecking with
6 | * LanguageTool and most other decorations. This is just a very simplified
7 | * version, that just highlights common white-space characters in western
8 | * languages.
9 | */
10 |
11 | const UnicodeRegexpSupport = /x/.unicode != null ? "gu" : "g";
12 | const InvisibleChars = [
13 | "\u0020", // Space
14 | "\u00a0", // No-Break Space
15 | "\u00ad", // Soft Hyphen
16 | "\u200b", // Zero-width Space
17 | "\u0009" // Tab
18 | ];
19 | const InvisiblesRegex = new RegExp(
20 | `(\u0020{2}$)|([${InvisibleChars.join("")}])`,
21 | UnicodeRegexpSupport
22 | );
23 |
24 | export default class Invisibles extends Extension {
25 | plugins() {
26 | const decorator = new MatchDecorator({
27 | regexp: InvisiblesRegex,
28 | decoration: (match) => {
29 | if (match[1]) {
30 | return Decoration.mark({ class: "cm-hardbreak" });
31 | }
32 |
33 | return Decoration.mark({
34 | class: "cm-invisible-char",
35 | attributes: { "data-code": match[2].charCodeAt(0) }
36 | });
37 | }
38 | });
39 |
40 | return [
41 | ViewPlugin.define(
42 | (view) => ({
43 | decorations: decorator.createDeco(view),
44 | update(u) {
45 | this.decorations = decorator.updateDeco(u, this.decorations);
46 | }
47 | }),
48 | {
49 | decorations: (v) => v.decorations
50 | }
51 | )
52 | ];
53 | }
54 |
55 | get type() {
56 | return "invisibles";
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/components/MarkdownField.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
15 |
16 | {{ label }}
17 |
18 |
19 |
20 |
28 |
29 |
30 |
31 |
92 |
--------------------------------------------------------------------------------
/src/variables.css:
--------------------------------------------------------------------------------
1 | .k-markdown-input-wrap {
2 | --cm-content-padding-y: .25rem;
3 | --cm-line-padding-x: var(--field-input-padding);
4 | --cm-font-size: var(--input-font-size);
5 | --cm-font-family: var(--font-mono);
6 | --cm-line-height: 1.5;
7 | --cm-code-background: rgba(0, 0, 0, 0.05);
8 | --cm-color-meta: var(--color-gray-500);
9 | --cm-color-light-gray: rgba(0, 0, 0, 0.1);
10 | --cm-selection-background: hsla(195, 80%, 40%, 0.16);
11 | --cm-color-special-char: #df5f5f;
12 | --cm-color-cursor: #5588ca;
13 | --cm-color-highlight-background: rgba(255, 230, 0, 0.4); /* #fdc500; */
14 | --cm-kirbytag-background: rgba(66, 113, 174, 0.1);
15 | --cm-kirbytag-underline: rgba(66, 113, 174, 0.3);
16 | --cm-min-lines: 2;
17 | }
18 |
19 | /* Font settings */
20 |
21 | .k-markdown-input-wrap[data-font-family="sans-serif"] {
22 | --cm-font-family: var(--font-sans);
23 | }
24 |
25 | /* Handle disabled state like core textarea */
26 |
27 | .k-input[data-type="markdown"][data-disabled="true"] {
28 | border: var(--field-input-border) !important;
29 | box-shadow: none !important;
30 | }
31 |
32 | .k-input[data-type="markdown"][data-disabled="true"] .cm-cursor {
33 | display: none !important;
34 | }
35 |
36 | /* Editor min-height */
37 |
38 | .k-markdown-input-wrap[data-size="one-line"] {
39 | --cm-min-lines: 1;
40 | }
41 |
42 | .k-markdown-input-wrap[data-size="two-lines"] {
43 | --cm-min-lines: 2;
44 | }
45 |
46 | .k-markdown-input-wrap[data-size="small"] {
47 | --cm-min-lines: 4;
48 | }
49 |
50 | .k-markdown-input-wrap[data-size="medium"] {
51 | --cm-min-lines: 8;
52 | }
53 |
54 | .k-markdown-input-wrap[data-size="large"] {
55 | --cm-min-lines: 16;
56 | }
57 |
58 | .k-markdown-input-wrap[data-size="huge"] {
59 | --cm-min-lines: 24;
60 | }
61 |
--------------------------------------------------------------------------------
/vendor/getkirby/composer-installer/src/ComposerInstaller/Plugin.php:
--------------------------------------------------------------------------------
1 |
12 | * @link https://getkirby.com
13 | * @copyright Bastian Allgeier GmbH
14 | * @license https://opensource.org/licenses/MIT
15 | */
16 | class Plugin implements PluginInterface
17 | {
18 | /**
19 | * Apply plugin modifications to Composer
20 | *
21 | * @param \Composer\Composer $composer
22 | * @param \Composer\IO\IOInterface $io
23 | * @return void
24 | */
25 | public function activate(Composer $composer, IOInterface $io): void
26 | {
27 | $installationManager = $composer->getInstallationManager();
28 | $installationManager->addInstaller(new CmsInstaller($io, $composer));
29 | $installationManager->addInstaller(new PluginInstaller($io, $composer));
30 | }
31 |
32 | /**
33 | * Remove any hooks from Composer
34 | *
35 | * @codeCoverageIgnore
36 | *
37 | * @param \Composer\Composer $composer
38 | * @param \Composer\IO\IOInterface $io
39 | * @return void
40 | */
41 | public function deactivate(Composer $composer, IOInterface $io): void
42 | {
43 | // nothing to do
44 | }
45 |
46 | /**
47 | * Prepare the plugin to be uninstalled
48 | *
49 | * @codeCoverageIgnore
50 | *
51 | * @param Composer $composer
52 | * @param IOInterface $io
53 | * @return void
54 | */
55 | public function uninstall(Composer $composer, IOInterface $io): void
56 | {
57 | // nothing to do
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/components/Extensions.js:
--------------------------------------------------------------------------------
1 | import BlockFormats from "./BlockFormats.js";
2 | import InlineFormats from "./InlineFormats.js";
3 |
4 | export default class Extensions {
5 | constructor(extensions = [], editor, input) {
6 | extensions.forEach((extension) => {
7 | extension.bindEditor(editor);
8 | extension.bindInput(input);
9 | extension.init();
10 | });
11 | this.extensions = extensions;
12 | }
13 |
14 | getPluginsByType(type = "extension") {
15 | return this.extensions
16 | .filter((extension) => extension.type === type)
17 | .reduce((result, extension) => [...result, ...extension.plugins()], []);
18 | }
19 |
20 | /**
21 | * Gets all button definitions for the editor toolbar.
22 | */
23 | getButtons() {
24 | return this.extensions
25 | .filter((extension) => extension.type === "button")
26 | .reduce((result, extension) => [...result, extension], []);
27 | }
28 |
29 | getDialogs() {
30 | return this.extensions
31 | .filter((extension) => extension.dialog)
32 | .reduce((result, extension) => [...result, extension], []);
33 | }
34 |
35 | getFormats(type) {
36 | const formats = this.extensions
37 | .filter((extension) => extension.syntax)
38 | .reduce((result, extension) => {
39 | let syntax = extension.syntax;
40 | syntax = Array.isArray(syntax) ? syntax : [syntax];
41 | syntax = syntax.filter((def) => def.type === type);
42 | result.push(...syntax);
43 | return result;
44 | }, []);
45 |
46 | return type === "block"
47 | ? new BlockFormats(formats)
48 | : new InlineFormats(formats);
49 | }
50 |
51 | /**
52 | * Generates the keymap from all registred extensions.
53 | */
54 | getKeymap() {
55 | return this.extensions
56 | .filter((extension) => extension.keys)
57 | .reduce((result, extension) => [...result, ...extension.keys()], []);
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/extension-examples/markdown-snippets/index.js:
--------------------------------------------------------------------------------
1 |
2 | // Dropdown with text snippets
3 | (function() {
4 |
5 | // Ensure, that the global `markdownEditorButtons` variable exists, otherwise
6 | // declare it. It is just a plain array, that gets read whenever the field is use.
7 | window.markdownEditorButtons = window.markdownEditorButtons || [];
8 |
9 | // Pass the plugin definition to the buttons array
10 | window.markdownEditorButtons.push({
11 |
12 | /**
13 | * This button has a dropdown, which contents are derived from
14 | * field config in your blueprint:
15 | *
16 | * text:
17 | * type: markdown
18 | * buttons:
19 | * - bold
20 | * - italic
21 | * snippets:
22 | * - value: "(alert: My text color: red)"
23 | * text: Alert box
24 | * - value: ":-)"
25 | * text: "Smiley"
26 | * - value: "Ⓐ"
27 | * text: "Anarchy symbol"
28 | */
29 | configure(options) {
30 | if (Array.isArray(options)) {
31 | // transform options array into commands that can be
32 | // understood by the editor plugin
33 | const dropdown = options.map(({ text: label, value }) => ({
34 | label,
35 | command: () => {
36 | this.editor.focus()
37 | this.editor.insert(value)
38 | }
39 | }))
40 |
41 | this.options = {
42 | ...this.defaults,
43 | dropdown
44 | }
45 | }
46 | },
47 |
48 | get button() {
49 | return {
50 | icon: "bolt",
51 | label: "Snippets",
52 | dropdown: this.options.dropdown,
53 | }
54 | },
55 |
56 | get isDisabled() {
57 | return () => false
58 | },
59 |
60 | get name() {
61 | return "snippets"
62 | },
63 | })
64 |
65 | })();
66 |
--------------------------------------------------------------------------------
/src/components/MarkdownBlock.vue:
--------------------------------------------------------------------------------
1 |
2 |
21 |
22 |
23 |
73 |
74 |
84 |
--------------------------------------------------------------------------------
/src/components/Buttons/HorizontalRule.js:
--------------------------------------------------------------------------------
1 | import { EditorSelection } from "@codemirror/state";
2 | import Button from "./Button.js";
3 | import { ltrim, rtrim } from "../Utils/strings.js";
4 |
5 | export default class HorizontalRule extends Button {
6 | get button() {
7 | return {
8 | icon: "separator",
9 | label: this.input.$t("markdown.toolbar.button.hr"),
10 | command: this.command
11 | };
12 | }
13 |
14 | get command() {
15 | return () => {
16 | const { view } = this.editor;
17 | const { state } = view;
18 | const selection = state.selection.main;
19 | let textBefore = rtrim(state.doc.slice(0, selection.from).toString());
20 | let textAfter = ltrim(state.doc.slice(selection.to).toString());
21 |
22 | textBefore =
23 | textBefore +
24 | (textBefore.length > 0 ? "\n\n" : "") +
25 | this.syntax.render();
26 | textAfter = "\n\n" + textAfter;
27 |
28 | view.dispatch({
29 | changes: {
30 | from: 0,
31 | to: state.doc.length,
32 | insert: textBefore + textAfter
33 | },
34 | selection: EditorSelection.cursor(textBefore.length),
35 | scrollIntoView: true
36 | });
37 | };
38 | }
39 |
40 | configure(options) {
41 | if (typeof options === "string") {
42 | options = { mark: options };
43 | }
44 |
45 | Button.prototype.configure.call(this, options);
46 |
47 | if (!["***", "---", "___"].includes(this.options.mark)) {
48 | throw "Horizontal rule mark must be either `***`, `---` or `___`.";
49 | }
50 | }
51 |
52 | get defaults() {
53 | return {
54 | mark: "***"
55 | };
56 | }
57 |
58 | get name() {
59 | return "hr";
60 | }
61 |
62 | get syntax() {
63 | return {
64 | token: this.token,
65 | type: this.tokenType,
66 | render: () => this.options.mark
67 | };
68 | }
69 |
70 | get token() {
71 | return "HorizontalRule";
72 | }
73 |
74 | get tokenType() {
75 | return "block";
76 | }
77 |
78 | get isActive() {
79 | return () => false;
80 | }
81 |
82 | get isDisabled() {
83 | return () => this.editor.isActiveToken("Kirbytag", "Link");
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/components/Extensions/PasteUrls.js:
--------------------------------------------------------------------------------
1 | import { ViewPlugin } from "@codemirror/view";
2 | import { Transaction } from "@codemirror/state";
3 |
4 | import Extension from "../Extension.js";
5 | import { isURL } from "../Utils/strings.js";
6 |
7 | export default class PasteUrls extends Extension {
8 | plugins() {
9 | const editor = this.editor;
10 | const useKirbytext = this.input.kirbytext;
11 |
12 | const pasteUrlsPlugin = ViewPlugin.define(() => ({}), {
13 | // eslint-disable-line no-unused-vars
14 | eventHandlers: {
15 | paste(e, view) {
16 | let pasted = e.clipboardData.getData("text");
17 |
18 | if (!isURL(pasted)) {
19 | return;
20 | }
21 |
22 | const { from, to } = view.state.selection.main;
23 |
24 | if (from === to) {
25 | // no selection
26 | return;
27 | }
28 |
29 | const firstLine = view.state.doc.lineAt(from).number;
30 | const lastLine = view.state.doc.lineAt(to).number;
31 |
32 | if (firstLine !== lastLine) {
33 | // Don’t apply to multiline selections
34 | return;
35 | } else if (editor.isActiveToken("Kirbytag")) {
36 | // Don’t apply to Kirbytags
37 | return;
38 | }
39 |
40 | e.preventDefault();
41 |
42 | if (useKirbytext && pasted.startsWith(window.panel.site)) {
43 | // Remove trailing URL for internal URLs
44 | pasted = pasted.substr(window.panel.site.length).replace(/^\//, "");
45 | }
46 |
47 | let [, prefix, linkText, suffix] = view.state
48 | .sliceDoc(from, to)
49 | .match(/^(\s*)(.*?)(\s*)$/);
50 | let link = useKirbytext
51 | ? `(link: ${pasted} text: ${linkText})`
52 | : `[${linkText}](${pasted})`;
53 |
54 | view.dispatch({
55 | changes: {
56 | insert: link,
57 | from: from + prefix.length,
58 | to: to - suffix.length
59 | },
60 | annotations: Transaction.userEvent.of("paste"),
61 | scrollIntoView: true
62 | });
63 |
64 | return true;
65 | }
66 | }
67 | });
68 |
69 | return [pasteUrlsPlugin];
70 | }
71 |
72 | get type() {
73 | return "language";
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/.php-cs-fixer.dist.php:
--------------------------------------------------------------------------------
1 | in(__DIR__);
5 |
6 | $config = new PhpCsFixer\Config();
7 | return $config
8 | ->setRules([
9 | '@PSR12' => true,
10 | 'align_multiline_comment' => ['comment_type' => 'phpdocs_like'],
11 | 'array_indentation' => true,
12 | 'array_syntax' => ['syntax' => 'short'],
13 | 'cast_spaces' => ['space' => 'none'],
14 | 'combine_consecutive_issets' => true,
15 | 'combine_consecutive_unsets' => true,
16 | 'combine_nested_dirname' => true,
17 | 'concat_space' => ['spacing' => 'one'],
18 | 'declare_equal_normalize' => ['space' => 'single'],
19 | 'dir_constant' => true,
20 | 'function_typehint_space' => true,
21 | 'include' => true,
22 | 'logical_operators' => true,
23 | 'lowercase_cast' => true,
24 | 'lowercase_static_reference' => true,
25 | 'magic_constant_casing' => true,
26 | 'magic_method_casing' => true,
27 | 'method_chaining_indentation' => true,
28 | 'modernize_types_casting' => true,
29 | 'multiline_comment_opening_closing' => true,
30 | 'native_function_casing' => true,
31 | 'native_function_type_declaration_casing' => true,
32 | 'new_with_braces' => true,
33 | 'no_blank_lines_after_class_opening' => true,
34 | 'no_blank_lines_after_phpdoc' => true,
35 | 'no_empty_comment' => true,
36 | 'no_empty_phpdoc' => true,
37 | 'no_empty_statement' => true,
38 | 'no_leading_namespace_whitespace' => true,
39 | 'no_mixed_echo_print' => ['use' => 'echo'],
40 | 'no_unneeded_control_parentheses' => true,
41 | 'no_unused_imports' => true,
42 | 'no_useless_return' => true,
43 | 'ordered_imports' => ['sort_algorithm' => 'alpha'],
44 | // 'phpdoc_add_missing_param_annotation' => ['only_untyped' => false], // adds params in the wrong order
45 | 'phpdoc_align' => ['align' => 'left'],
46 | 'phpdoc_indent' => true,
47 | 'phpdoc_scalar' => true,
48 | 'phpdoc_trim' => true,
49 | 'short_scalar_cast' => true,
50 | 'single_line_comment_style' => true,
51 | 'single_quote' => true,
52 | 'ternary_to_null_coalescing' => true,
53 | 'whitespace_after_comma_in_array' => true
54 | ])
55 | ->setRiskyAllowed(true)
56 | ->setIndent("\t")
57 | ->setFinder($finder);
58 |
--------------------------------------------------------------------------------
/vendor/getkirby/composer-installer/src/ComposerInstaller/CmsInstaller.php:
--------------------------------------------------------------------------------
1 |
12 | * @link https://getkirby.com
13 | * @copyright Bastian Allgeier GmbH
14 | * @license https://opensource.org/licenses/MIT
15 | */
16 | class CmsInstaller extends Installer
17 | {
18 | /**
19 | * Decides if the installer supports the given type
20 | *
21 | * @param string $packageType
22 | * @return bool
23 | */
24 | public function supports($packageType): bool
25 | {
26 | return $packageType === 'kirby-cms';
27 | }
28 |
29 | /**
30 | * Returns the installation path of a package
31 | *
32 | * @param \Composer\Package\PackageInterface $package
33 | * @return string
34 | */
35 | public function getInstallPath(PackageInterface $package): string
36 | {
37 | // get the extra configuration of the top-level package
38 | if ($rootPackage = $this->composer->getPackage()) {
39 | $extra = $rootPackage->getExtra();
40 | } else {
41 | $extra = [];
42 | }
43 |
44 | // use path from configuration, otherwise fall back to default
45 | if (isset($extra['kirby-cms-path']) === true) {
46 | $path = $extra['kirby-cms-path'];
47 | } else {
48 | $path = 'kirby';
49 | }
50 |
51 | // if explicitly set to something invalid (e.g. `false`), install to vendor dir
52 | if (is_string($path) !== true) {
53 | return parent::getInstallPath($package);
54 | }
55 |
56 | // don't allow unsafe directories
57 | $vendorDir = $this->composer->getConfig()->get('vendor-dir', Config::RELATIVE_PATHS) ?? 'vendor';
58 | if ($path === $vendorDir || $path === '.') {
59 | throw new InvalidArgumentException('The path ' . $path . ' is an unsafe installation directory for ' . $package->getPrettyName() . '.');
60 | }
61 |
62 | return $path;
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/vendor/composer/installed.json:
--------------------------------------------------------------------------------
1 | {
2 | "packages": [
3 | {
4 | "name": "getkirby/composer-installer",
5 | "version": "1.2.1",
6 | "version_normalized": "1.2.1.0",
7 | "source": {
8 | "type": "git",
9 | "url": "https://github.com/getkirby/composer-installer.git",
10 | "reference": "c98ece30bfba45be7ce457e1102d1b169d922f3d"
11 | },
12 | "dist": {
13 | "type": "zip",
14 | "url": "https://api.github.com/repos/getkirby/composer-installer/zipball/c98ece30bfba45be7ce457e1102d1b169d922f3d",
15 | "reference": "c98ece30bfba45be7ce457e1102d1b169d922f3d",
16 | "shasum": ""
17 | },
18 | "require": {
19 | "composer-plugin-api": "^1.0 || ^2.0"
20 | },
21 | "require-dev": {
22 | "composer/composer": "^1.8 || ^2.0"
23 | },
24 | "time": "2020-12-28T12:54:39+00:00",
25 | "type": "composer-plugin",
26 | "extra": {
27 | "class": "Kirby\\ComposerInstaller\\Plugin"
28 | },
29 | "installation-source": "dist",
30 | "autoload": {
31 | "psr-4": {
32 | "Kirby\\": "src/"
33 | }
34 | },
35 | "notification-url": "https://packagist.org/downloads/",
36 | "license": [
37 | "MIT"
38 | ],
39 | "description": "Kirby's custom Composer installer for the Kirby CMS and for Kirby plugins",
40 | "homepage": "https://getkirby.com",
41 | "support": {
42 | "issues": "https://github.com/getkirby/composer-installer/issues",
43 | "source": "https://github.com/getkirby/composer-installer/tree/1.2.1"
44 | },
45 | "funding": [
46 | {
47 | "url": "https://getkirby.com/buy",
48 | "type": "custom"
49 | }
50 | ],
51 | "install-path": "../getkirby/composer-installer"
52 | }
53 | ],
54 | "dev": false,
55 | "dev-package-names": []
56 | }
57 |
--------------------------------------------------------------------------------
/phpmd.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/src/components/Extensions/FirefoxBlurFix.js:
--------------------------------------------------------------------------------
1 | import Extension from "../Extension.js";
2 | import { ViewPlugin } from "@codemirror/view";
3 | import { debounce } from "underscore";
4 |
5 | import browser from "../Utils/browser.js";
6 |
7 | // https://stackoverflow.com/questions/35939886/find-first-scrollable-parent
8 | function getScrollParent(element, includeHidden = false) {
9 | var style = getComputedStyle(element);
10 | var excludeStaticParent = style.position === "absolute";
11 | var overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/;
12 |
13 | if (style.position === "fixed") return document.body;
14 | for (var parent = element; (parent = parent.parentElement); ) {
15 | style = getComputedStyle(parent);
16 | if (excludeStaticParent && style.position === "static") {
17 | continue;
18 | }
19 | if (overflowRegex.test(style.overflow + style.overflowY + style.overflowX))
20 | return parent;
21 | }
22 |
23 | return document.body;
24 | }
25 |
26 | export default class FirefoxBlurFix extends Extension {
27 | plugins() {
28 | if (!browser.gecko) {
29 | // Don’t return the plugin for other browsers than Firefox
30 | return [];
31 | }
32 |
33 | // There’s a strange bug in Firefox, that causes the scrollable parent
34 | // container of the editor to jump, when the use blurs the editor and the
35 | // editor is taller, than the user’s viewport. As I could not find the root
36 | // cause of this issue, this plugin provides a temporary fix by storing the
37 | // editor’s scroll position, when the user clicks somewhere else.
38 | return [
39 | ViewPlugin.define(
40 | (view) => {
41 | view.$$scrollParent = getScrollParent(view.dom);
42 | view.$$scrollParentTop = 0;
43 | view.$$updateScrollParentTop = debounce(() => {
44 | view.$$scrollParentTop = view.$$scrollParent.scrollTop;
45 | }, 50);
46 |
47 | view.$$updateScrollParentTop();
48 | },
49 | {
50 | eventHandlers: {
51 | blur(eventName, view) {
52 | view.$$scrollParent.scrollTo(
53 | view.$$scrollParent.scrollLeft,
54 | view.$$scrollParentTop
55 | );
56 | },
57 | scroll(eventName, view) {
58 | view.$$updateScrollParentTop();
59 | }
60 | }
61 | }
62 | )
63 | ];
64 | }
65 |
66 | get type() {
67 | return "language";
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/components/Utils/keymap.js:
--------------------------------------------------------------------------------
1 | // Based on https://github.com/codemirror/view/blob/main/src/keymap.ts
2 | const currentPlatform =
3 | typeof navigator === "undefined"
4 | ? "key"
5 | : /Mac/.test(navigator.platform)
6 | ? "mac"
7 | : /Win/.test(navigator.platform)
8 | ? "win"
9 | : /Linux|X11/.test(navigator.platform)
10 | ? "linux"
11 | : "key";
12 |
13 | export function formatKeyName(keys, translate, before = " (", after = ")") {
14 | let keyName = keys[currentPlatform]
15 | ? keys[currentPlatform]
16 | : keys.key
17 | ? keys.key
18 | : null;
19 |
20 | if (keyName === null) {
21 | return "";
22 | }
23 |
24 | const parts = keyName.split(/-(?!$)/);
25 | let result = parts[parts.length - 1];
26 |
27 | if (result === "Space") {
28 | result = currentPlatform === "mac" ? "␣" : translate("markdown.key.space");
29 | }
30 |
31 | let alt;
32 | let ctrl;
33 | let shift;
34 | let meta;
35 |
36 | for (let i = 0; i < parts.length - 1; ++i) {
37 | const mod = parts[i];
38 |
39 | if (/^(cmd|meta|m)$/i.test(mod)) {
40 | meta = true;
41 | } else if (/^a(lt)?$/i.test(mod)) {
42 | alt = true;
43 | } else if (/^(c|ctrl|control)$/i.test(mod)) {
44 | ctrl = true;
45 | } else if (/^s(hift)?$/i.test(mod)) {
46 | shift = true;
47 | } else if (/^mod$/i.test(mod)) {
48 | if (currentPlatform === "mac") {
49 | meta = true;
50 | } else {
51 | ctrl = true;
52 | }
53 | } else {
54 | throw new Error("Unrecognized modifier name: " + mod);
55 | }
56 | }
57 |
58 | if (currentPlatform === "mac") {
59 | // On the Mac platform, it is common to use symbols for
60 | // displaying keyboard shortcuts.
61 | if (meta) {
62 | result = "⌘" + result;
63 | }
64 |
65 | if (alt) {
66 | result = "⌥" + result;
67 | }
68 |
69 | if (shift) {
70 | result = "⇧" + result;
71 | }
72 |
73 | if (ctrl) {
74 | result = "⌃" + result;
75 | }
76 |
77 | return before + result.toUpperCase() + after;
78 | }
79 |
80 | if (shift) {
81 | result = translate("markdown.key.shift") + "+" + result;
82 | }
83 |
84 | if (ctrl) {
85 | result = translate("markdown.key.ctrl") + "+" + result;
86 | }
87 |
88 | if (alt) {
89 | result = translate("markdown.key.alt") + "+" + result;
90 | }
91 |
92 | if (meta) {
93 | result = translate("markdown.key.meta") + "+" + result;
94 | }
95 |
96 | return before + result + after;
97 | }
98 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import "./variables.css";
2 | import "./syntax.css";
3 |
4 | import Toolbar from "./components/MarkdownToolbar.vue";
5 |
6 | import MarkdownBlock from "./components/MarkdownBlock.vue";
7 | import MarkdownField from "./components/MarkdownField.vue";
8 | import MarkdownInput from "./components/MarkdownInput.vue";
9 |
10 | window.panel.plugin("fabianmichael/markdown-field", {
11 | components: {
12 | "k-markdown-input": MarkdownInput,
13 | "k-markdown-toolbar": Toolbar
14 | },
15 | blocks: {
16 | markdown: MarkdownBlock
17 | },
18 | fields: {
19 | markdown: MarkdownField
20 | },
21 | icons: {
22 | "special-chars":
23 | '',
24 | footnote:
25 | '',
26 | highlight:
27 | '',
28 | eraser:
29 | '',
30 | separator:
31 | ''
32 | }
33 | });
34 |
--------------------------------------------------------------------------------
/extension-examples/custom-pagelink/index.js:
--------------------------------------------------------------------------------
1 | (function() {
2 |
3 | // Ensure, that the global `markdownEditorButtons` variable exists, otherwise
4 | // declare it. It is just a plain array, that gets read whenever the field is use.
5 | window.markdownEditorButtons = window.markdownEditorButtons || [];
6 |
7 | // Pass the plugin definition to the buttons array
8 | window.markdownEditorButtons.push({
9 | /**
10 | * This button is a custom pagelink page picker, whose query and info are derived from
11 | * the field config in your blueprint:
12 | *
13 | * text:
14 | * type: markdown
15 | * buttons:
16 | * albums:
17 | * pages:
18 | * query: site.find('albums')
19 | * info: "{{ page.title.uppercase }}"
20 | */
21 |
22 | /**
23 | * The button definition. This button just opens the dialog, when clicked.
24 | */
25 | get button() {
26 | return {
27 | icon: "document",
28 | label: "Insert Album",
29 | command: () => {
30 | /* The pages endpoint should be set to the pattern /(:any)/pages
31 | * where :any is the name of your extension and the corresponding
32 | * key in the blueprint query and info fields.
33 | */
34 | this.editor.emit("dialog", this, {
35 | endpoint: this.input.endpoints.field + "/albums/pages",
36 | multiple: false,
37 | selected: [],
38 | });
39 | }
40 | };
41 | },
42 | /**
43 | * What the button is actually supposed to do, when the dialog’s form gets submitted
44 | * In this case, we reuse the logic from the original pagelink button.
45 | */
46 | get command() {
47 | return (selected) => {
48 | if (this.isDisabled()) {
49 | return;
50 | }
51 |
52 | if (!selected || !selected.length) {
53 | return;
54 | }
55 |
56 | const page = selected[0];
57 | const selection = this.editor.getSelection();
58 | const text = selection.length > 0 ? selection : page.text || page.title;
59 | const lang = this.input.currentLanguage && !this.input.currentLanguage.default ? ` lang: ${this.input.currentLanguage.code}` : "";
60 | const tag = `(link: ${page.id} text: ${text}${lang})`;
61 |
62 | this.editor.insert(tag);
63 | };
64 | },
65 |
66 | /**
67 | * Must be a unique identifier. Use the `toolbar` field property in your blueprints,
68 | * to add this button to a Markdown field’s toolbar.
69 | */
70 | get name() {
71 | return "albums";
72 | },
73 |
74 | /**
75 | * Name of the dialog component. In this case, we reuse the native k-pages-dialog.
76 | */
77 | get dialog() {
78 | return "k-pages-dialog";
79 | }
80 | });
81 |
82 | })();
83 |
--------------------------------------------------------------------------------
/src/components/Extensions/LineStyles.js:
--------------------------------------------------------------------------------
1 | import { Decoration } from "@codemirror/view";
2 | import { RangeSetBuilder } from "@codemirror/state";
3 | import { ViewPlugin } from "@codemirror/view";
4 | import { getBlockNameAt } from "../Utils/syntax.js";
5 | import Extension from "../Extension.js";
6 |
7 | function lineDeco(view, blockFormats) {
8 | const builder = new RangeSetBuilder();
9 |
10 | for (let { from, to } of view.visibleRanges) {
11 | let lastLine = null;
12 |
13 | for (let pos = from; pos <= to; ) {
14 | const line = view.state.doc.lineAt(pos);
15 | const blockToken = getBlockNameAt(
16 | view,
17 | blockFormats,
18 | pos + line.text.match(/^\s*/)[0].length
19 | );
20 | let matches = null;
21 |
22 | if (blockFormats.exists(blockToken)) {
23 | const style = blockFormats.get(blockToken);
24 |
25 | if (!style.mark) {
26 | // Block type without mark
27 | builder.add(
28 | line.from,
29 | line.from,
30 | Decoration.line({ attributes: { class: style.class } })
31 | );
32 | } else if (style.mark) {
33 | matches = line.text.match(style.mark);
34 |
35 | /*if (matches && style.multiLine && lastLine) {
36 | // continued block format without marker
37 | matches = lastLine.matches;
38 | const [, prefix, mark, suffix] = matches;
39 | builder.add(
40 | line.from,
41 | line.from,
42 | Decoration.line({
43 | attributes: {
44 | class: style.class,
45 | style: `--cm-indent: ${
46 | prefix.length + mark.length + suffix.length
47 | }ch;`
48 | }
49 | })
50 | );
51 | } else*/if (matches) {
52 | // first line
53 | const [, prefix, mark, suffix] = matches;
54 | builder.add(
55 | line.from,
56 | line.from,
57 | Decoration.line({
58 | attributes: {
59 | class: style.class,
60 | style: `--cm-indent: ${prefix.length}ch; --cm-mark: ${
61 | mark.length + suffix.length
62 | }ch;`
63 | }
64 | })
65 | );
66 | }
67 | }
68 | }
69 |
70 | // lastLine = {
71 | // token: blockToken,
72 | // matches: matches
73 | // };
74 |
75 | pos = line.to + 1;
76 | }
77 | }
78 |
79 | return builder.finish();
80 | }
81 |
82 | export default class LineStyles extends Extension {
83 | plugins() {
84 | const blockFormats = this.editor.blockFormats;
85 |
86 | return [
87 | ViewPlugin.fromClass(
88 | class {
89 | constructor(view) {
90 | this.decorations = lineDeco(view, blockFormats);
91 | }
92 |
93 | update(update) {
94 | if (update.docChanged || update.viewportChanged) {
95 | this.decorations = lineDeco(update.view, blockFormats);
96 | }
97 | }
98 | },
99 | {
100 | decorations: (v) => v.decorations
101 | }
102 | )
103 | ];
104 | }
105 |
106 | get type() {
107 | return "language";
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/vendor/getkirby/composer-installer/readme.md:
--------------------------------------------------------------------------------
1 | # Kirby Composer Installer
2 |
3 | [](https://github.com/getkirby/composer-installer/actions?query=workflow%3ACI)
4 | [](https://coveralls.io/github/getkirby/composer-installer)
5 |
6 | This is Kirby's custom [Composer installer](https://getcomposer.org/doc/articles/custom-installers.md) for the Kirby CMS.
7 | It is responsible for automatically choosing the correct installation paths if you install the CMS via Composer.
8 |
9 | It can also be used to automatically install Kirby plugins to the `site/plugins` directory.
10 |
11 | ## Installing the CMS
12 |
13 | ### Default configuration
14 |
15 | If you `require` the `getkirby/cms` package in your own `composer.json`, there is nothing else you need to do:
16 |
17 | ```js
18 | {
19 | "require": {
20 | "getkirby/cms": "^3.0"
21 | }
22 | }
23 | ```
24 |
25 | Kirby's Composer installer (this repo) will run automatically and will install the CMS to the `kirby` directory.
26 |
27 | ### Custom installation path
28 |
29 | You might want to use a different installation path. The path can be configured like this in your `composer.json`:
30 |
31 | ```js
32 | {
33 | "require": {
34 | "getkirby/cms": "^3.0"
35 | },
36 | "extra": {
37 | "kirby-cms-path": "kirby" // change this to your custom path
38 | }
39 | }
40 | ```
41 |
42 | ### Disable the installer for the CMS
43 |
44 | If you prefer to have the CMS installed to the `vendor` directory, you can disable the custom path entirely:
45 |
46 | ```js
47 | {
48 | "require": {
49 | "getkirby/cms": "^3.0"
50 | },
51 | "extra": {
52 | "kirby-cms-path": false
53 | }
54 | }
55 | ```
56 |
57 | Please note that you will need to modify your site's `index.php` to load the `vendor/autoload.php` file instead of Kirby's `bootstrap.php`.
58 |
59 | ## Installing plugins
60 |
61 | ### Support in published plugins
62 |
63 | Plugins need to require this installer as a Composer dependency to make use of the automatic installation to the `site/plugins` directory.
64 |
65 | You can find out more about this in our [plugin documentation](https://getkirby.com/docs/guide/plugins/plugin-setup-basic).
66 |
67 | ### Usage for plugin users
68 |
69 | As a user of Kirby plugins that support this installer, you only need to `require` the plugins in your site's `composer.json`:
70 |
71 | ```js
72 | {
73 | "require": {
74 | "getkirby/cms": "^3.0",
75 | "superwoman/superplugin": "^1.0"
76 | }
77 | }
78 | ```
79 |
80 | The installer (this repo) will run automatically, as the plugin dev added it to the plugin's `composer.json`.
81 |
82 | ### Custom installation path
83 |
84 | If your `site/plugins` directory is at a custom path, you can configure the installation path like this in your `composer.json`:
85 |
86 | ```js
87 | {
88 | "require": {
89 | "getkirby/cms": "^3.0",
90 | "superwoman/superplugin": "^1.0"
91 | },
92 | "extra": {
93 | "kirby-plugin-path": "site/plugins" // change this to your custom path
94 | }
95 | }
96 | ```
97 |
98 | ## License
99 |
100 |
101 |
102 | ## Author
103 |
104 | Lukas Bestle
105 |
--------------------------------------------------------------------------------
/src/components/Extensions/TaskLists.js:
--------------------------------------------------------------------------------
1 | import { ViewPlugin, Decoration } from "@codemirror/view";
2 | import { RangeSetBuilder } from "@codemirror/state";
3 | import { syntaxTree } from "@codemirror/language";
4 | import Extension from "../Extension.js";
5 |
6 | function checkboxes(view) {
7 | let b = new RangeSetBuilder();
8 |
9 | for (let { from, to } of view.visibleRanges) {
10 | syntaxTree(view.state).iterate({
11 | enter: ({ name, from, to }) => {
12 | if (name !== "TaskMarker") {
13 | return;
14 | }
15 |
16 | const isTrue = view.state.doc.sliceString(from, to) === "[x]";
17 | b.add(
18 | from,
19 | to,
20 | Decoration.mark({
21 | class: "cm-taskmarker is-" + (isTrue ? "checked" : "unchecked")
22 | })
23 | );
24 | },
25 | from,
26 | to
27 | });
28 | }
29 |
30 | return b.finish();
31 | }
32 |
33 | function toggleTaskListCheckbox(view, pos) {
34 | let old = view.state.doc.sliceString(
35 | pos,
36 | Math.min(pos + 3, view.state.doc.length)
37 | );
38 | let insert;
39 | if (old == "[ ]") {
40 | insert = "[x]";
41 | } else if (old === "[x]") {
42 | insert = "[ ]";
43 | } else {
44 | return false;
45 | }
46 |
47 | view.dispatch({ changes: { from: pos, to: pos + 3, insert } });
48 | return true;
49 | }
50 |
51 | function toggleListItemsComplete(view) {
52 | const firstLine = view.state.doc.lineAt(view.state.selection.main.from);
53 | const lastLine = view.state.doc.lineAt(view.state.selection.main.to);
54 | let markers = [];
55 |
56 | syntaxTree(view.state).iterate({
57 | enter: ({ name, from, to }) => {
58 | if (name !== "TaskMarker") return;
59 | markers.push({
60 | from,
61 | to,
62 | checked: view.state.doc.sliceString(from, to) === "[x]"
63 | });
64 | },
65 | from: firstLine.from,
66 | to: lastLine.to
67 | });
68 |
69 | const allChecked = markers.filter((v) => !v.checked).length === 0;
70 |
71 | markers.forEach(({ from, to }) => {
72 | const checkbox = allChecked ? "[ ]" : "[x]";
73 | view.dispatch({
74 | changes: { from, to, insert: checkbox }
75 | });
76 | });
77 | }
78 |
79 | export default class TaskLists extends Extension {
80 | keys() {
81 | return [
82 | {
83 | key: "Cmd-.",
84 | run: toggleListItemsComplete,
85 | preventDefault: true
86 | }
87 | ];
88 | }
89 |
90 | plugins() {
91 | const taskListPlugin = ViewPlugin.fromClass(
92 | class {
93 | constructor(view) {
94 | this.decorations = checkboxes(view);
95 | }
96 |
97 | update(update) {
98 | if (update.docChanged || update.viewportChanged) {
99 | this.decorations = checkboxes(update.view);
100 | }
101 | }
102 | },
103 | {
104 | decorations: (v) => v.decorations,
105 |
106 | eventHandlers: {
107 | mousedown: ({ target }, view) => {
108 | if (
109 | (target.classList &&
110 | target.classList.contains("cm-taskmarker")) ||
111 | target.closest(".cm-taskmarker")
112 | ) {
113 | return toggleTaskListCheckbox(view, view.posAtDOM(target));
114 | }
115 | }
116 | }
117 | }
118 | );
119 |
120 | return [taskListPlugin];
121 | }
122 |
123 | get token() {
124 | return "TaskMarker";
125 | }
126 |
127 | get tokenType() {
128 | return "inline";
129 | }
130 |
131 | get type() {
132 | return "language";
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/extension-examples/dialog/index.js:
--------------------------------------------------------------------------------
1 | (function() {
2 |
3 | // Ensure, that the global `markdownEditorButtons` variable exists, otherwise
4 | // declare it. It is just a plain array, that gets read whenever the field is use.
5 | window.markdownEditorButtons = window.markdownEditorButtons || [];
6 |
7 | // Pass the plugin definition to the buttons array
8 | window.markdownEditorButtons.push({
9 | /**
10 | * The button definition. This button just opens the dialog, when clicked.
11 | */
12 | get button() {
13 | return {
14 | icon: "twitter",
15 | label: "Insert Twitter Link",
16 | command: () => this.editor.emit("dialog", this),
17 | };
18 | },
19 | /**
20 | * What the button is actually supposed to do, when the dialog’s form gets submitted
21 | */
22 | get command() {
23 | return ({ username }) => {
24 | this.editor.insert(`(twitter: ${username})`);
25 | };
26 | },
27 |
28 | /**
29 | * Must be a unique identifier. Use the `toolbar` field property in your blueprints,
30 | * to add this button to a Markdown field’s toolbar.
31 | */
32 | get name() {
33 | return "twitter";
34 | },
35 |
36 | /**
37 | * Name of the dialog component. Must be registred globally, see below.
38 | */
39 | get dialog() {
40 | return "k-markdown-twitter-dialog";
41 | }
42 | });
43 |
44 | // Definition of the dialog component. Of course, you could also use single-file
45 | // components and a dedicated build step for more complex plugins.
46 | const TwitterDialog = {
47 | template: `
48 |
54 |
60 |
61 | `,
62 | props: {
63 | extension: Object,
64 | },
65 | data() {
66 | return {
67 | value: this.defaultValue(),
68 | fields: {
69 | username: {
70 | label: "Twitter username",
71 | type: "text",
72 | icon: "twitter"
73 | },
74 | },
75 | };
76 | },
77 | methods: {
78 | cancel() {
79 | this.$emit("cancel");
80 | },
81 | defaultValue() {
82 | return {
83 | username: null,
84 | }
85 | },
86 | /**
87 | * Each plugin dialog must have an open method, because that’s what the Markdown
88 | * field will call to open the dialog.
89 | */
90 | open() {
91 | // make sure we're starting with an empty form
92 | this.resetValue();
93 | this.$refs.dialog.open();
94 | },
95 | resetValue() {
96 | this.value = this.defaultValue();
97 | },
98 | submit() {
99 | this.$refs.dialog.close();
100 |
101 | // Sanitize value before submit
102 | this.value.username = this.value.username ? this.value.username.replace(/^@/, '') : "";
103 |
104 | // Pass value to extension command
105 | this.$emit("submit", this.value);
106 | },
107 | }
108 | };
109 |
110 | window.panel.plugin("my/markdown-dialog", {
111 | components: {
112 | "k-markdown-twitter-dialog": TwitterDialog,
113 | }
114 | });
115 |
116 | })();
117 |
--------------------------------------------------------------------------------
/src/components/Buttons/Headlines.js:
--------------------------------------------------------------------------------
1 | import Button from "./Button.js";
2 |
3 | export default class Headlines extends Button {
4 | constructor(options = {}) {
5 | super(options);
6 | }
7 |
8 | get button() {
9 | return {
10 | icon: "title",
11 | label: this.input.$t("toolbar.button.headings"),
12 | dropdown: this.dropdownItems().filter((item) =>
13 | this.options.levels.includes(item.name)
14 | )
15 | };
16 | }
17 |
18 | configure(options) {
19 | if (Array.isArray(options)) {
20 | options = { levels: options };
21 | }
22 |
23 | Button.prototype.configure.call(this, options);
24 | }
25 |
26 | get defaults() {
27 | return {
28 | levels: ["h1", "h2", "h3"]
29 | };
30 | }
31 |
32 | dropdownItems() {
33 | return [
34 | {
35 | name: "h1",
36 | icon: "h1",
37 | label:
38 | this.input.$t("markdown.toolbar.button.heading.1") +
39 | this.formatKeyName(
40 | { mac: "Ctrl-Alt-1", key: "Alt-Shift-1" },
41 | "",
42 | ""
43 | ),
44 | command: () => this.editor.toggleBlockFormat("ATXHeading1"),
45 | token: "ATXHeading1",
46 | tokenType: "block"
47 | },
48 | {
49 | name: "h2",
50 | icon: "h2",
51 | label:
52 | this.input.$t("markdown.toolbar.button.heading.2") +
53 | this.formatKeyName(
54 | { mac: "Ctrl-Alt-2", key: "Alt-Shift-2" },
55 | "",
56 | ""
57 | ),
58 | command: () => this.editor.toggleBlockFormat("ATXHeading2"),
59 | token: "ATXHeading2",
60 | tokenType: "block"
61 | },
62 | {
63 | name: "h3",
64 | icon: "h3",
65 | label:
66 | this.input.$t("markdown.toolbar.button.heading.3") +
67 | this.formatKeyName(
68 | { mac: "Ctrl-Alt-3", key: "Alt-Shift-3" },
69 | "",
70 | ""
71 | ),
72 | command: () => this.editor.toggleBlockFormat("ATXHeading3"),
73 | token: "ATXHeading3",
74 | tokenType: "block"
75 | },
76 | {
77 | name: "h4",
78 | icon: "h4",
79 | label:
80 | this.input.$t("markdown.toolbar.button.heading.4") +
81 | this.formatKeyName(
82 | { mac: "Ctrl-Alt-4", key: "Alt-Shift-4" },
83 | "",
84 | ""
85 | ),
86 | command: () => this.editor.toggleBlockFormat("ATXHeading4"),
87 | token: "ATXHeading4",
88 | tokenType: "block"
89 | },
90 | {
91 | name: "h5",
92 | icon: "h5",
93 | label:
94 | this.input.$t("markdown.toolbar.button.heading.5") +
95 | this.formatKeyName(
96 | { mac: "Ctrl-Alt-5", key: "Alt-Shift-5" },
97 | "",
98 | ""
99 | ),
100 | command: () => this.editor.toggleBlockFormat("ATXHeading5"),
101 | token: "ATXHeading5",
102 | tokenType: "block"
103 | },
104 | {
105 | name: "h6",
106 | icon: "h6",
107 | label:
108 | this.input.$t("markdown.toolbar.button.heading.6") +
109 | this.formatKeyName(
110 | { mac: "Ctrl-Alt-6", key: "Alt-Shift-6" },
111 | "",
112 | ""
113 | ),
114 | command: () => this.editor.toggleBlockFormat("ATXHeading6"),
115 | token: "ATXHeading6",
116 | tokenType: "block"
117 | }
118 | ];
119 | }
120 |
121 | get isDisabled() {
122 | return () => false;
123 | }
124 |
125 | keys() {
126 | return this.options.levels.reduce((accumulator, level) => {
127 | level = level.replace(/^h/, "");
128 | return [
129 | ...accumulator,
130 | {
131 | mac: `Ctrl-Alt-${level}`,
132 | key: `Alt-Shift-${level}`,
133 | run: () => this.editor.toggleBlockFormat(`ATXHeading${level}`),
134 | preventDefault: true
135 | }
136 | ];
137 | }, []);
138 | }
139 |
140 | get name() {
141 | return "headlines";
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/vendor/getkirby/composer-installer/src/ComposerInstaller/Installer.php:
--------------------------------------------------------------------------------
1 |
14 | * @link https://getkirby.com
15 | * @copyright Bastian Allgeier GmbH
16 | * @license https://opensource.org/licenses/MIT
17 | */
18 | class Installer extends LibraryInstaller
19 | {
20 | /**
21 | * Decides if the installer supports the given type
22 | *
23 | * @param string $packageType
24 | * @return bool
25 | */
26 | public function supports($packageType): bool
27 | {
28 | throw new RuntimeException('This method needs to be overridden.'); // @codeCoverageIgnore
29 | }
30 |
31 | /**
32 | * Installs a specific package
33 | *
34 | * @param \Composer\Repository\InstalledRepositoryInterface $repo Repository in which to check
35 | * @param \Composer\Package\PackageInterface $package Package instance to install
36 | * @return \React\Promise\PromiseInterface|null
37 | */
38 | public function install(InstalledRepositoryInterface $repo, PackageInterface $package)
39 | {
40 | // first install the package normally...
41 | $promise = parent::install($repo, $package);
42 |
43 | // ...then run custom code
44 | $postInstall = function () use ($package): void {
45 | $this->postInstall($package);
46 | };
47 |
48 | // Composer 2 in async mode
49 | if ($promise instanceof PromiseInterface) {
50 | return $promise->then($postInstall);
51 | }
52 |
53 | // Composer 1 or Composer 2 without async
54 | $postInstall();
55 | }
56 |
57 | /**
58 | * Updates a specific package
59 | *
60 | * @param \Composer\Repository\InstalledRepositoryInterface $repo Repository in which to check
61 | * @param \Composer\Package\PackageInterface $initial Already installed package version
62 | * @param \Composer\Package\PackageInterface $target Updated version
63 | * @return \React\Promise\PromiseInterface|null
64 | *
65 | * @throws \InvalidArgumentException if $initial package is not installed
66 | */
67 | public function update(InstalledRepositoryInterface $repo, PackageInterface $initial, PackageInterface $target)
68 | {
69 | // first update the package normally...
70 | $promise = parent::update($repo, $initial, $target);
71 |
72 | // ...then run custom code
73 | $postInstall = function () use ($target): void {
74 | $this->postInstall($target);
75 | };
76 |
77 | // Composer 2 in async mode
78 | if ($promise instanceof PromiseInterface) {
79 | return $promise->then($postInstall);
80 | }
81 |
82 | // Composer 1 or Composer 2 without async
83 | $postInstall();
84 | }
85 |
86 | /**
87 | * Custom handler that will be called after each package
88 | * installation or update
89 | *
90 | * @param \Composer\Package\PackageInterface $package
91 | * @return void
92 | */
93 | protected function postInstall(PackageInterface $package)
94 | {
95 | // remove the package's `vendor` directory to avoid duplicated autoloader and vendor code
96 | $packageVendorDir = $this->getInstallPath($package) . '/vendor';
97 | if (is_dir($packageVendorDir) === true) {
98 | $success = $this->filesystem->removeDirectory($packageVendorDir);
99 |
100 | if ($success !== true) {
101 | throw new RuntimeException('Could not completely delete ' . $packageVendorDir . ', aborting.'); // @codeCoverageIgnore
102 | }
103 | }
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/vendor/getkirby/composer-installer/src/ComposerInstaller/PluginInstaller.php:
--------------------------------------------------------------------------------
1 |
11 | * @link https://getkirby.com
12 | * @copyright Bastian Allgeier GmbH
13 | * @license https://opensource.org/licenses/MIT
14 | */
15 | class PluginInstaller extends Installer
16 | {
17 | /**
18 | * Decides if the installer supports the given type
19 | *
20 | * @param string $packageType
21 | * @return bool
22 | */
23 | public function supports($packageType): bool
24 | {
25 | return $packageType === 'kirby-plugin';
26 | }
27 |
28 | /**
29 | * Returns the installation path of a package
30 | *
31 | * @param \Composer\Package\PackageInterface $package
32 | * @return string path
33 | */
34 | public function getInstallPath(PackageInterface $package): string
35 | {
36 | // place into `vendor` directory as usual if Pluginkit is not supported
37 | if ($this->supportsPluginkit($package) !== true) {
38 | return parent::getInstallPath($package);
39 | }
40 |
41 | // get the extra configuration of the top-level package
42 | if ($rootPackage = $this->composer->getPackage()) {
43 | $extra = $rootPackage->getExtra();
44 | } else {
45 | $extra = [];
46 | }
47 |
48 | // use base path from configuration, otherwise fall back to default
49 | $basePath = $extra['kirby-plugin-path'] ?? 'site/plugins';
50 |
51 | if (is_string($basePath) !== true) {
52 | throw new InvalidArgumentException('Invalid "kirby-plugin-path" option');
53 | }
54 |
55 | // determine the plugin name from its package name;
56 | // can be overridden in the plugin's `composer.json`
57 | $prettyName = $package->getPrettyName();
58 | $pluginExtra = $package->getExtra();
59 | if (empty($pluginExtra['installer-name']) === false) {
60 | $name = $pluginExtra['installer-name'];
61 |
62 | if (is_string($name) !== true) {
63 | throw new InvalidArgumentException('Invalid "installer-name" option in plugin ' . $prettyName);
64 | }
65 | } elseif (strpos($prettyName, '/') !== false) {
66 | // use name after the slash
67 | $name = explode('/', $prettyName)[1];
68 | } else {
69 | $name = $prettyName;
70 | }
71 |
72 | // build destination path from base path and plugin name
73 | return $basePath . '/' . $name;
74 | }
75 |
76 | /**
77 | * Custom handler that will be called after each package
78 | * installation or update
79 | *
80 | * @param \Composer\Package\PackageInterface $package
81 | * @return void
82 | */
83 | protected function postInstall(PackageInterface $package): void
84 | {
85 | // only continue if Pluginkit is supported
86 | if ($this->supportsPluginkit($package) !== true) {
87 | return;
88 | }
89 |
90 | parent::postInstall($package);
91 | }
92 |
93 | /**
94 | * Checks if the package has explicitly required this installer;
95 | * otherwise (if the Pluginkit is not yet supported by the plugin)
96 | * the installer will fall back to the behavior of the LibraryInstaller
97 | *
98 | * @param \Composer\Package\PackageInterface $package
99 | * @return bool
100 | */
101 | protected function supportsPluginkit(PackageInterface $package): bool
102 | {
103 | foreach ($package->getRequires() as $link) {
104 | if ($link->getTarget() === 'getkirby/composer-installer') {
105 | return true;
106 | }
107 | }
108 |
109 | // no required package is the installer
110 | return false;
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/src/components/Buttons/Link.js:
--------------------------------------------------------------------------------
1 | import Button from "./Button.js";
2 |
3 | export default class Link extends Button {
4 | get button() {
5 | return {
6 | icon: "url",
7 | label:
8 | this.input.$t("toolbar.button.link") +
9 | this.formatKeyName(this.keys()[0]),
10 | command: () => this.openDialog()
11 | };
12 | }
13 |
14 | openDialog() {
15 | const selection = this.editor.view.viewState.state.selection.main;
16 | const contents = this.editor.view.viewState.state.sliceDoc(
17 | selection.from,
18 | selection.to
19 | );
20 |
21 | const fields = {
22 | href: {
23 | label: window.panel.$t("link"),
24 | type: "link",
25 | placeholder: window.panel.$t("url.placeholder"),
26 | icon: "url"
27 | },
28 | text: {
29 | label: window.panel.$t("link.text"),
30 | type: "text",
31 | placeholder: contents
32 | }
33 | };
34 |
35 | if (this.useKirbytext) {
36 | fields["target"] = {
37 | label: window.panel.$t("open.newWindow"),
38 | type: "toggle",
39 | text: [window.panel.$t("no"), window.panel.$t("yes")]
40 | };
41 | }
42 |
43 | this.input.$panel.dialog.open({
44 | component: "k-link-dialog",
45 | props: {
46 | fields,
47 | value: ""
48 | },
49 | on: {
50 | cancel: () => this.input.focus(),
51 | submit: (values) => {
52 | this.input.$panel.dialog.close();
53 | delete values.title;
54 | values.text = values.text || contents || null;
55 | this.insertLink(values);
56 | }
57 | }
58 | });
59 | }
60 |
61 | insertLink({ href, text, target }) {
62 | if (this.isDisabled()) {
63 | return;
64 | }
65 |
66 | if (href === "" || href === null) {
67 | return;
68 | }
69 |
70 | const hasText = text !== "" && text !== null;
71 | const linkType = this.linkType(href);
72 |
73 | if (linkType === "email") {
74 | const email = href.replace(/^email:/, "");
75 |
76 | if (this.useKirbytext) {
77 | const textAttr = hasText ? ` text: ${text}` : "";
78 | this.editor.insert(`(email: ${email}${textAttr})`);
79 | } else if (hasText) {
80 | this.editor.insert(`[${text}](mailto:${email})`);
81 | } else {
82 | this.editor.insert(`<${email}>`);
83 | }
84 | } else {
85 | if (this.useKirbytext) {
86 | const textAttr = hasText ? ` text: ${text}` : "";
87 | const targetAttr = target ? " target: _blank" : "";
88 | this.editor.insert(`(link: ${href}${textAttr}${targetAttr})`);
89 | } else if (hasText) {
90 | this.editor.insert(`[${text}](${href})`);
91 | } else {
92 | this.editor.insert(`<${href}>`);
93 | }
94 | }
95 | }
96 |
97 | linkType(value) {
98 | if (typeof value !== "string") {
99 | return "custom";
100 | }
101 |
102 | if (/^(http|https):\/\//.test(value)) {
103 | return "url";
104 | }
105 |
106 | if (value.startsWith("page://") || value.startsWith("/@/page/")) {
107 | return "page";
108 | }
109 |
110 | if (value.startsWith("file://") || value.startsWith("/@/file/")) {
111 | return "file";
112 | }
113 |
114 | if (value.startsWith("tel:")) {
115 | return "tel";
116 | }
117 |
118 | if (value.startsWith("email:")) {
119 | return "email";
120 | }
121 |
122 | if (value.startsWith("#")) {
123 | return "#";
124 | }
125 |
126 | return "custom";
127 | }
128 |
129 | configure(options) {
130 | if (typeof options === "string") {
131 | options = { style: options };
132 | }
133 |
134 | Button.prototype.configure.call(this, options);
135 |
136 | if (!["markdown", "kirbytext", null].includes(this.options.style)) {
137 | throw "Link style must be either `markdown`, `kirbytext` or `null`.";
138 | }
139 | }
140 |
141 | get defaults() {
142 | return {
143 | blank: true,
144 | style: null
145 | };
146 | }
147 |
148 | get useKirbytext() {
149 | return (
150 | [null, "kirbytext"].includes(this.options.style) && this.input.kirbytext
151 | );
152 | }
153 |
154 | keys() {
155 | return [
156 | {
157 | key: "Mod-k",
158 | run: () => this.openDialog()
159 | }
160 | ];
161 | }
162 |
163 | get name() {
164 | return "link";
165 | }
166 | }
167 |
--------------------------------------------------------------------------------
/src/components/Extensions/ImagePreview.js:
--------------------------------------------------------------------------------
1 | import { syntaxTree } from "@codemirror/language";
2 | import { RangeSet } from "@codemirror/state";
3 | import { StateField } from "@codemirror/state";
4 | import { Decoration, EditorView, WidgetType } from "@codemirror/view";
5 |
6 | import Extension from "../Extension.js";
7 |
8 | class ImageWidget extends WidgetType {
9 | constructor(options) {
10 | super();
11 | this.url = options.url;
12 | this.extension = options.extension;
13 | }
14 |
15 | eq(imageWidget) {
16 | return imageWidget.url === this.url;
17 | }
18 |
19 | toDOM() {
20 | const container = document.createElement("div");
21 | const backdrop = container.appendChild(document.createElement("div"));
22 | const figure = backdrop.appendChild(document.createElement("figure"));
23 | const image = figure.appendChild(document.createElement("img"));
24 |
25 | container.setAttribute("aria-hidden", "true");
26 | container.style.pointerEvents = "none";
27 | container.className = "cm-line cm-image-container";
28 | backdrop.className = "cm-image-backdrop";
29 | figure.className = "cm-image-figure";
30 | image.className = "cm-image-img";
31 | // image.style = "outline: 1px dotted red"
32 | // image.src = this.url
33 |
34 | container.style.paddingBottom =
35 | "calc(var(--cm-font-size) * var(--cm-line-height) / 4)";
36 | container.style.paddingTop =
37 | "calc(var(--cm-font-size) * var(--cm-line-height) / 4)";
38 |
39 | const parent = this.extension.input.$store.getters["content/id"]();
40 |
41 | this.extension.input.$api.files
42 | .get(parent, this.url, { select: ["thumbs", "url"] })
43 | .then((file) => {
44 | // console.log("file info", file);
45 | // image.src = result.url
46 | if (file.thumbs) {
47 | image.src = file.thumbs.tiny;
48 | image.srcset = `${file.thumbs.tiny} 1x, ${file.thumbs.small} 2x`;
49 | } else {
50 | image.src = file.url;
51 | }
52 | });
53 |
54 | // backdrop.style.backgroundColor = "var(--hybrid-mde-image-backdrop-color, rgba(0, 0, 0, 0.3))"
55 | // backdrop.style.display = "flex"
56 | // backdrop.style.alignItems = "center"
57 | // backdrop.style.justifyContent = "start"
58 | // backdrop.style.padding = "1rem"
59 | backdrop.style.maxWidth = "100%";
60 |
61 | figure.style.margin = "0";
62 |
63 | image.style.display = "block";
64 | image.style.height =
65 | "calc(var(--cm-font-size) * var(--cm-line-height) * 2.5)";
66 | image.style.maxWidth = "100%";
67 | image.style.width = "min(8rem, 100%)";
68 | image.style.objectFit = "contain";
69 | image.style.objectPosition = "0% 50%";
70 |
71 | return container;
72 | }
73 | }
74 |
75 | // const imageRegex = /!\[.*\]\((.*)\)/
76 | const imageRegex = /\(image:\s*([^\s()]+)/;
77 |
78 | const imageDecoration = (imageWidgetParams) =>
79 | Decoration.widget({
80 | widget: new ImageWidget(imageWidgetParams),
81 | side: -1,
82 | block: true
83 | });
84 |
85 | const decorate = (extension, state) => {
86 | const widgets = [];
87 |
88 | syntaxTree(state).iterate({
89 | enter: ({ type, from, to }) => {
90 | if (type.name === "Kirbytag") {
91 | const result = state.doc.sliceString(from, to).match(imageRegex);
92 | const url = result ? result[1] : null;
93 | // console.log("Kirbytag", result);
94 |
95 | if (url) {
96 | widgets.push(
97 | imageDecoration({ extension, url }).range(
98 | state.doc.lineAt(from).from
99 | )
100 | );
101 | }
102 | }
103 | }
104 | });
105 | // widgets.push(imageDecoration({ url }).range(state.doc.lineAt(from).from))
106 |
107 | return widgets.length > 0 ? RangeSet.of(widgets) : Decoration.none;
108 | };
109 |
110 | export default class ImagePreview extends Extension {
111 | plugins() {
112 | const extension = this;
113 |
114 | const imageField = StateField.define({
115 | create(state) {
116 | return decorate(extension, state);
117 | },
118 | update(images, transaction) {
119 | if (transaction.docChanged) {
120 | return decorate(extension, transaction.state);
121 | }
122 |
123 | return images.map(transaction.changes);
124 | },
125 | provide(field) {
126 | return EditorView.decorations.from(field);
127 | }
128 | });
129 |
130 | return [imageField];
131 | }
132 |
133 | get type() {
134 | return "language";
135 | }
136 | }
137 |
138 | // window.panel.app.$store.subscribeAction((action, state) => {
139 | // console.log(action.type)
140 | // console.log(action.payload)
141 | // })
142 |
--------------------------------------------------------------------------------
/fields/markdown.php:
--------------------------------------------------------------------------------
1 | 'textarea',
6 | 'props' => [
7 | /**
8 | * Sets the toolbar buttons.
9 | */
10 | 'buttons' => function ($buttons = true) {
11 | if ($buttons === false || empty($buttons) === true) {
12 | return false;
13 | }
14 |
15 | if ($buttons === true) {
16 | return true;
17 | }
18 |
19 | $def = [];
20 | $divider = 0;
21 |
22 | foreach ($buttons as $type => $button) {
23 | if (is_int($type) === true && is_string($button) === true) {
24 | if ($button === 'divider') {
25 | $button = 'divider__' . $divider++;
26 | }
27 |
28 | $def[$button] = new stdClass();
29 | }
30 |
31 | if (is_string($type) === true) {
32 | $def[$type] = $button;
33 | }
34 | }
35 |
36 | return $def;
37 | },
38 |
39 | /**
40 | * Sets the font family (sans or monospace)
41 | */
42 | 'font' => function (string $font = null) {
43 | return $font === 'sans-serif' ? 'sans-serif' : 'monospace';
44 | },
45 |
46 | /**
47 | * Min-height of the field when empty. String.
48 | */
49 | 'size' => function (?string $size = null) {
50 | return $size;
51 | },
52 |
53 | /**
54 | * Sets the custom query for the page selector dialog.
55 | */
56 | 'pages' => function ($pages = []) {
57 | if (is_string($pages) === true) {
58 | return ['query' => $pages];
59 | }
60 | if (is_array($pages) === false) {
61 | $pages = [];
62 | }
63 | return $pages;
64 | },
65 |
66 | /**
67 | * Sets the custom query for the page selector dialog.
68 | * @deprecated Use `pages` instead
69 | */
70 | 'query' => function ($query = null) {
71 | return $query;
72 | },
73 |
74 | 'info' => function ($info = null) {
75 | return $info;
76 | },
77 |
78 | 'highlights' => function ($highlights = true) {
79 | return $highlights;
80 | },
81 |
82 | 'kirbytext' => function (bool $kirbytext = true) {
83 | return $kirbytext;
84 | },
85 | ],
86 | 'computed' => [
87 | /**
88 | * Returns an array of known KirbyTags, used by the syntax highlighter.
89 | * Highlighting only known KirbyTags decreases the chance of false
90 | * positives.
91 | */
92 | 'knownKirbytags' => function () {
93 | return array_keys($this->kirby()->extensions('tags'));
94 | },
95 | 'customHighlights' => function () {
96 | $highlights = [];
97 |
98 | foreach ($this->kirby()->plugins() as $plugin) {
99 | $highlights = array_merge(
100 | $highlights,
101 | array_map(
102 | fn ($highlight) => is_callable($highlight) ? $highlight() : $highlight,
103 | $plugin->extends()['fabianmichael.markdown-field.customHighlights'] ?? []
104 | )
105 | );
106 | }
107 |
108 | foreach ($this->kirby()->option('fabianmichael.markdown-field.customHighlights', []) as $highlight) {
109 | $highlights[] = is_callable($highlight) ? $highlight() : $highlight;
110 | }
111 |
112 | return $highlights;
113 | }
114 | ],
115 | 'api' => function () {
116 | return [
117 | [
118 | 'pattern' => ['files', '(:any)/files'],
119 | 'method' => 'GET',
120 | 'action' => function ($button = null) {
121 | $field = $this->field();
122 |
123 | $params = $field->files();
124 | // allow buttons to override base params
125 | if ($button) {
126 | $buttonProps = $field->buttons()[$button] ?? [];
127 | if (is_array($buttonProps)) {
128 | $buttonParams = $buttonProps['files'] ?? [];
129 | $params = array_merge($params, $buttonParams);
130 | }
131 | }
132 |
133 | $params = array_merge($params, [
134 | 'page' => $this->requestQuery('page'),
135 | 'search' => $this->requestQuery('search')
136 | ]);
137 |
138 | return $this->field()->filepicker($params);
139 | }
140 | ],
141 | [
142 | 'pattern' => ['upload', '(:any)/upload'],
143 | 'method' => 'POST',
144 | 'action' => function ($button = null) {
145 | $field = $this->field();
146 | $uploads = $field->uploads();
147 |
148 | // allow buttons to override base params
149 | if ($button) {
150 | $buttonProps = $field->buttons()[$button] ?? [];
151 | if (is_array($buttonProps)) {
152 | $buttonParams = $buttonProps['uploads'] ?? [];
153 | $uploads = array_merge($uploads, $buttonParams);
154 | }
155 | }
156 |
157 | return $this->field()->upload($this, $uploads, function ($file, $parent) use ($field) {
158 | $absolute = $field->model()->is($parent) === false;
159 |
160 | return [
161 | 'filename' => $file->filename(),
162 | 'dragText' => $file->panel()->dragText('auto', $absolute),
163 | ];
164 | });
165 | }
166 | ],
167 | ];
168 | },
169 | ];
170 |
--------------------------------------------------------------------------------
/src/components/Extensions/FilePicker.js:
--------------------------------------------------------------------------------
1 | // import {
2 | // Decoration,
3 | // ViewPlugin,
4 | // WidgetType
5 | // } from "@codemirror/view";
6 | // import Extension from "../Extension.js";
7 |
8 | // export default class FilePicker extends Extension {
9 |
10 | // constructor(options = {}) {
11 | // super(options);
12 | // this.replaceFrom = null;
13 | // this.replaceTo = null;
14 | // }
15 |
16 | // get openSelectDialog() {
17 | // return (selected) => this.editor.emit("dialog", this, {
18 | // endpoint: this.input.endpoints.field + "/files",
19 | // multiple: false,
20 | // selected,
21 | // })
22 | // }
23 |
24 | // get command() {
25 | // return (selected) => {
26 | // if (!selected || !selected.length) {
27 | // return;
28 | // }
29 |
30 | // const file = selected[0];
31 |
32 | // this.editor.dispatch({
33 | // changes: { from: this.replaceFrom, to: this.replaceTo, insert: file.filename }
34 | // });
35 | // }
36 | // }
37 |
38 | // get dialog() {
39 | // return "k-files-dialog";
40 | // }
41 |
42 | // plugins() {
43 | // const extension = this;
44 |
45 | // class ImageWidget extends WidgetType {
46 | // constructor({ url }) {
47 | // super();
48 |
49 | // this.url = url;
50 | // }
51 |
52 | // eq(imageWidget) {
53 | // return imageWidget.url === this.url;
54 | // }
55 |
56 | // toDOM() {
57 | // const el = document.createElement("span");
58 | // el.className = "cm-file-button";
59 | // Object.assign(el.style, {
60 | // background: "red",
61 | // display: "inline-block",
62 | // width: "1ch",
63 | // height: "1ch",
64 | // // lineHeight: "0",
65 | // verticalAlign: "baseline",
66 | // cursor: "pointer",
67 | // });
68 | // el.onclick = () => {
69 | // console.log("clicko");
70 | // const pos = extension.editor.view.posAtDOM(el);
71 | // const line = extension.editor.state.doc.lineAt(pos);
72 | // const filename = extension.editor.state.sliceDoc(pos, line.to).match(/^[^\s)]+/)[0];
73 | // // const filename = extension.editor.state.doc.lineAt(pos).text.slice().match(//)
74 | // console.log("content", filename)
75 | // extension.replaceFrom = pos;
76 | // extension.replaceTo = pos + filename.length;
77 | // // extension.openSelectDialog();
78 | // // console.log("ii", ;
79 | // const page = extension.input.endpoints.model.split("/", 2)[1];
80 |
81 | // extension.openSelectDialog([page + "/" + filename]);
82 | // }
83 | // // el.innerHTML = "…";
84 | // return el;
85 | // }
86 | // }
87 |
88 | // const fileSelectorPlugin = ViewPlugin.fromClass(
89 | // class KirbytagsHighlighter {
90 | // constructor(view) {
91 | // this.decorations = this.mkDeco(view);
92 | // }
93 |
94 | // update(update) {
95 | // if (update.viewportChanged || update.docChanged)
96 | // this.decorations = this.mkDeco(update.view);
97 | // }
98 |
99 | // mkDeco(view) {
100 | // // let b = new RangeSetBuilder();
101 | // const widgets = [];
102 | // // let regex = new RegExp(`(\\((?:${tagNamesPattern}):)|(\\()|(\\))`, "gi");
103 |
104 | // for (let { from, to } of view.visibleRanges) {
105 | // let range = view.state.sliceDoc(from, to);
106 | // let match;
107 | // const regex = /(\((?:image|file):\s*)([^\s)]+)/g;
108 |
109 | // while ((match = regex.exec(range))) {
110 | // console.log("image tag found", match);
111 | // widgets.push(
112 | // Decoration.widget({
113 | // widget: new ImageWidget({ url: ""}),
114 | // side: 1
115 | // }).range(from + match.index + match[1].length)
116 | // );
117 | // }
118 | // }
119 |
120 | // // return b.finish();
121 | // return Decoration.set(widgets);
122 | // }
123 | // },
124 | // {
125 | // decorations: (v) => v.decorations,
126 |
127 | // eventHandlers: {
128 | // mousedown: ({ target }, view) => { // eslint-disable-line no-unused-vars
129 | // if (target.classList && target.classList.contains("cm-file-button") || target.closest(".cm-file-button")) {
130 | // this.openSelectDialog();
131 | // }
132 | // }
133 | // }
134 | // }
135 | // );
136 |
137 | // return [
138 | // fileSelectorPlugin,
139 | // ]
140 | // }
141 |
142 | // get type() {
143 | // return "language";
144 | // }
145 | // }
146 |
--------------------------------------------------------------------------------
/src/components/Extensions/Theme.js:
--------------------------------------------------------------------------------
1 | import { EditorView, ViewPlugin } from "@codemirror/view";
2 | import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
3 | import { tags as t } from "@lezer/highlight";
4 | import { tags as kirbytextTags } from "./KirbytextLanguage.js";
5 | import Extension from "../Extension.js";
6 |
7 | function theme() {
8 | return EditorView.theme(
9 | {
10 | "&.cm-editor.cm-focused": {
11 | outline: "none"
12 | },
13 |
14 | "&.focused ::selection": {
15 | background: "var(--cm-selection-background)"
16 | },
17 |
18 | ".cm-scroller": {
19 | fontFamily: "var(--cm-font-family)",
20 | lineHeight: "var(--cm-line-height)",
21 | fontSize: "var(--cm-font-size)",
22 | overflow: "visible" // Ensures, that no scrollbar will ever become visible on the editor element.
23 | },
24 |
25 | ".cm-content": {
26 | padding: "var(--cm-content-padding-y) 0",
27 | overflowWrap: "break-word", // prevents long, unbreakable word from creating a horizontal scrollbar
28 | wordBreak: "break-word",
29 | minHeight:
30 | "calc(2 * var(--cm-content-padding-y) + var(--cm-min-lines, 1) * 1em * var(--cm-line-height))", // prevents the editor from collapsing under certain cirtumstances
31 | width: "100%", // required to wrap all lines, that would be too long for the viewport.
32 | whiteSpace: "pre-wrap", // CM’s default 'break-spaces' would cause different wrapping, when inivible characters are shown.
33 | caretColor: "auto" // override CM’s default black caret color, whoch looks a bit strange on iOS
34 | },
35 |
36 | /**
37 | * 1. Ensures, that scrolling to a line takes height of the
38 | * toolbar and Kirby’s save bar into account. Probably does
39 | * not work in Safari (v14).
40 | */
41 | ".cm-line": {
42 | margin: "0",
43 | padding: "0",
44 | scrollMargin: "3.5rem 0" /* 1 */
45 | },
46 |
47 | ".cm-cursor": {
48 | position: "absolute",
49 | borderLeft: "2px solid currentColor",
50 | marginLeft: "-1px"
51 | },
52 |
53 | "&.cm-focused .cm-cursor": {
54 | color: "var(--cm-color-cursor)"
55 | },
56 |
57 | "&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
58 | backgroundColor: "var(--cm-selection-background)"
59 | // "backgroundColor": "Highlight",
60 | // "opacity": "0.27",
61 | },
62 |
63 | ".cm-codeblock": {
64 | margin: "0 calc(.25 * var(--cm-line-margin))",
65 | padding: "0 calc(.75 * var(--cm-line-margin))"
66 | }
67 | },
68 | { dark: false }
69 | );
70 | }
71 |
72 | function highlightStyle() {
73 | return syntaxHighlighting(
74 | HighlightStyle.define([
75 | {
76 | tag: t.contentSeparator,
77 | color: "currentColor",
78 | fontWeight: "700"
79 | },
80 | {
81 | tag: [
82 | t.heading1,
83 | t.heading2,
84 | t.heading3,
85 | t.heading4,
86 | t.heading5,
87 | t.heading6
88 | ],
89 | fontWeight: "700",
90 | color: "currentColor"
91 | },
92 | {
93 | tag: kirbytextTags.highlight,
94 | backgroundColor: "var(--cm-color-highlight-background)",
95 | color: "var(--color-text) !important"
96 | // padding: ".1em 0",
97 | // margin: "-.1em 0",
98 | },
99 | {
100 | tag: t.strong,
101 | fontWeight: "700",
102 | color: "currentColor"
103 | },
104 | {
105 | tag: t.emphasis,
106 | fontStyle: "italic",
107 | color: "currentColor"
108 | },
109 | {
110 | tag: [
111 | t.name,
112 | t.angleBracket,
113 | t.operator,
114 | t.meta,
115 | t.comment,
116 | t.processingInstruction,
117 | t.string,
118 | t.inserted
119 | ],
120 | color: "var(--cm-color-meta)"
121 | },
122 | {
123 | tag: t.atom,
124 | color: "currentColor" // just there, so it can be picked-up by extensions
125 | },
126 | {
127 | // table header
128 | tag: t.heading,
129 | fontWeight: "700"
130 | },
131 |
132 | {
133 | tag: t.strikethrough,
134 | textDecoration: "line-through"
135 | },
136 | {
137 | tag: t.url,
138 | color: "var(--cm-color-meta)"
139 | },
140 | {
141 | // HTML Entity
142 | tag: t.character,
143 | color: "currentColor"
144 | },
145 | {
146 | // Inline Code,
147 | tag: kirbytextTags.inlineCode,
148 | backgroundColor: "var(--cm-code-background)",
149 | padding: ".1em 0",
150 | margin: "-.1em 0"
151 | // borderRadius: ".125em",
152 | },
153 | {
154 | tag: [t.labelName],
155 | fontWeight: "400"
156 | },
157 | {
158 | tag: [kirbytextTags.kirbytag],
159 | background: "var(--cm-kirbytag-background)",
160 | color: "var(--color-text)",
161 | fontWeight: "400",
162 | margin: "-0.125em 0",
163 | padding: "0.0625em 0"
164 | }
165 | ])
166 | );
167 | }
168 |
169 | function scrollMargin() {
170 | return ViewPlugin.fromClass(
171 | class {
172 | constructor() {
173 | // eslint-disable-line no-unused-vars
174 | this.margin = {
175 | bottom: 60,
176 | top: 60
177 | };
178 | }
179 |
180 | // update(update) {
181 | // // Your update logic here
182 | // // this.margin = {left: 100}
183 | // }
184 | },
185 | {
186 | provide: (plugin) =>
187 | EditorView.scrollMargins.of((view) => {
188 | let value = view.plugin(plugin);
189 | return value;
190 | })
191 | }
192 | );
193 | }
194 |
195 | export default class Theme extends Extension {
196 | plugins() {
197 | return [theme(), highlightStyle(), scrollMargin(), EditorView.lineWrapping];
198 | }
199 |
200 | get type() {
201 | return "theme";
202 | }
203 | }
204 |
--------------------------------------------------------------------------------
/src/components/Extensions/URLs.js:
--------------------------------------------------------------------------------
1 | import { ViewPlugin, Decoration } from "@codemirror/view";
2 | import { syntaxTree } from "@codemirror/language";
3 | import { RangeSetBuilder } from "@codemirror/state";
4 | import Extension from "../Extension.js";
5 | import browser from "../Utils/browser.js";
6 | import { isURL } from "../Utils/strings.js";
7 |
8 | /**
9 | * Handle modifier key for clickable URLs globally, so it does not depend on the
10 | * editor being focused.
11 | */
12 | let isModifierKeydown = false;
13 |
14 | function toggleModifierKeydown(e) {
15 | const isTrue = browser.mac || browser.ios ? e.metaKey : e.ctrlKey; // CMD on Apple devices, otherwise CTRL
16 |
17 | if (isTrue === isModifierKeydown) {
18 | return;
19 | }
20 |
21 | if (isTrue) {
22 | document.documentElement.setAttribute("data-markdown-modkey", "true");
23 | } else {
24 | document.documentElement.removeAttribute("data-markdown-modkey");
25 | }
26 |
27 | isModifierKeydown = isTrue;
28 | }
29 |
30 | window.addEventListener("keydown", toggleModifierKeydown);
31 | window.addEventListener("keyup", toggleModifierKeydown);
32 | window.addEventListener("onpagehide", () =>
33 | toggleModifierKeydown({ metaKey: false, ctrlKey: false })
34 | );
35 | window.addEventListener("blur", () =>
36 | toggleModifierKeydown({ metaKey: false, ctrlKey: false })
37 | );
38 | document.addEventListener("visibilitychange", () =>
39 | document.hidden
40 | ? toggleModifierKeydown({ metaKey: false, ctrlKey: false })
41 | : null
42 | );
43 |
44 | /**
45 | * Use a custom highlighter, for being able to click URL elements and
46 | * for better styling control.
47 | */
48 | function highlightURLs(extension, view) {
49 | const b = new RangeSetBuilder();
50 |
51 | for (let { from, to } of view.visibleRanges) {
52 | syntaxTree(view.state).iterate({
53 | enter: ({ name, from, to }) => {
54 | if (name === "URL") {
55 | // Markdown URL token
56 | const [, prefix, url, suffix] = view.state.doc
57 | .sliceString(from, to)
58 | .match(/^()(.*?)(>?)$/);
59 |
60 | b.add(
61 | from + prefix.length,
62 | to - suffix.length,
63 | Decoration.mark({
64 | class: "cm-url",
65 | attributes: {
66 | "data-url": url
67 | }
68 | })
69 | );
70 | } else if (name === "Kirbytag") {
71 | // URL within Kirbytag
72 |
73 | const match = view.state.doc
74 | .sliceString(from, to)
75 | .match(/^\((image|file|link|email)(:\s*)([^\s)]+)/);
76 |
77 | if (!match) {
78 | return;
79 | }
80 |
81 | const [, tag, tagSuffix, url] = match;
82 | let attributes = null;
83 |
84 | if (["file", "image"].includes(tag)) {
85 | if (isURL(url)) {
86 | // external image/file
87 | attributes = { "data-url": url };
88 | } else if (!url.includes("/")) {
89 | // on same page
90 | const api = extension.input.$store.getters["content/model"]().api;
91 | attributes = { "data-panel-url": `${api}/files/${url}` };
92 | } else {
93 | // other page
94 | let lastIndex = url.lastIndexOf("/");
95 | attributes = {
96 | "data-panel-url": `/pages/${url.substr(
97 | 0,
98 | lastIndex
99 | )}/files/${url.substr(lastIndex + 1)}`
100 | };
101 | }
102 | } else if (["link", "video", "gist"].includes(tag)) {
103 | if (isURL(url) || url.startsWith("/")) {
104 | attributes = { "data-url": url };
105 | } else if (tag === "link") {
106 | attributes = {
107 | "data-panel-url": `/pages/${url.replace("/", "+")}`
108 | };
109 | }
110 | } else if (tag === "email") {
111 | attributes = { "data-url": `mailto:${url}`, "data-sametab": true };
112 | }
113 |
114 | if (attributes) {
115 | b.add(
116 | from + 1 + tag.length + tagSuffix.length,
117 | from + match[0].length,
118 | Decoration.mark({
119 | class: "cm-url cm-kirbytag-url",
120 | attributes
121 | })
122 | );
123 | }
124 | }
125 | },
126 | from,
127 | to
128 | });
129 | }
130 |
131 | return b.finish();
132 | }
133 |
134 | export default class URLs extends Extension {
135 | plugins() {
136 | const extension = this;
137 |
138 | const clickableLinksPlugin = ViewPlugin.fromClass(
139 | class {
140 | constructor(view) {
141 | this.decorations = highlightURLs(extension, view);
142 | }
143 |
144 | update(update) {
145 | if (update.docChanged || update.viewportChanged) {
146 | this.decorations = highlightURLs(extension, update.view);
147 | }
148 | }
149 | },
150 | {
151 | decorations: (v) => v.decorations,
152 |
153 | eventHandlers: {
154 | click(e) {
155 | if (e.metaKey) {
156 | const link = e.target.classList.contains("cm-url")
157 | ? e.target
158 | : e.target.closest(".cm-url");
159 |
160 | if (!link) {
161 | return;
162 | }
163 |
164 | if (/^[a-z]+:\/\/$/.test(link.dataset.url)) {
165 | // Don’t do anything, when target URL was empty (e.g. "https://")
166 | return;
167 | }
168 |
169 | if (link.dataset.panelUrl) {
170 | extension.input.$go(link.dataset.panelUrl);
171 | return;
172 | }
173 |
174 | if (link.dataset.sametab) {
175 | window.location.href = link.dataset.url;
176 | } else {
177 | window.open(link.dataset.url, "_blank", "noopener,noreferrer");
178 | }
179 | }
180 | }
181 | }
182 | }
183 | );
184 |
185 | return [clickableLinksPlugin];
186 | }
187 |
188 | get token() {
189 | return "URL";
190 | }
191 |
192 | get tokenType() {
193 | return "inline";
194 | }
195 |
196 | get type() {
197 | return "language";
198 | }
199 | }
200 |
--------------------------------------------------------------------------------
/src/components/MarkdownToolbar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
67 |
68 |
69 |
120 |
121 |
216 |
--------------------------------------------------------------------------------
/src/components/Extensions/KirbytextLanguage.js:
--------------------------------------------------------------------------------
1 | import { styleTags, Tag, tags as defaultTags } from "@lezer/highlight";
2 | import {
3 | markdown,
4 | markdownKeymap,
5 | markdownLanguage
6 | } from "@codemirror/lang-markdown";
7 | import Extension from "../Extension.js";
8 |
9 | // Custom style tags
10 |
11 | export const tags = {
12 | highlight: Tag.define(),
13 | kirbytag: Tag.define(),
14 | inlineCode: Tag.define()
15 | };
16 |
17 | // Parser extension for recognizing Kirbytags
18 |
19 | function Kirbytag(knownTags) {
20 | const tagNamesPattern = knownTags.join("|");
21 |
22 | return {
23 | defineNodes: ["Kirbytag"],
24 | parseInline: [
25 | {
26 | name: "Kirbytag",
27 | parse(cx, next, pos) {
28 | if (next != 40 /* '(' */) {
29 | return -1;
30 | }
31 |
32 | let after = cx.slice(pos, cx.end);
33 | let regex = new RegExp(
34 | `(\\((?:${tagNamesPattern}):)|(\\()|(\\))`,
35 | "gi"
36 | );
37 |
38 | let level = 0;
39 | let match;
40 | let inTag = false;
41 |
42 | while ((match = regex.exec(after))) {
43 | if (!inTag && !match[1]) {
44 | // no match and not in tag
45 | return -1;
46 | }
47 | if (!inTag && match[1]) {
48 | // kirbytag start, e.g. `(image:`
49 | inTag = true;
50 | level += 1;
51 | } else if (inTag && (match[1] || match[2])) {
52 | // in tag and open bracket `(` or start of nested tag
53 | level += 1;
54 | } else if (inTag && match[3]) {
55 | // in Tag and close bracket `)`
56 | level -= 1;
57 |
58 | if (level === 0) {
59 | return cx.addElement(
60 | cx.elt("Kirbytag", pos, pos + match.index + match[0].length)
61 | );
62 | }
63 | }
64 | }
65 |
66 | // No tag found
67 | return -1;
68 | },
69 | before: "Emphasis"
70 | }
71 | ],
72 | props: [
73 | styleTags({
74 | Kirbytag: tags.kirbytag
75 | })
76 | ]
77 | };
78 | }
79 |
80 | // Support for the `==highlight==` => `highlight` syntax
81 |
82 | const HighlightDelim = { resolve: "Highlight", mark: "HighlightMark" };
83 |
84 | const Highlight = {
85 | defineNodes: ["Highlight", "HighlightMark"],
86 | parseInline: [
87 | {
88 | name: "Highlight",
89 | parse(cx, next, pos) {
90 | if (next != 61 /* '=' */ || cx.char(pos + 1) != 61) {
91 | return -1;
92 | }
93 | return cx.addDelimiter(HighlightDelim, pos, pos + 2, true, true);
94 | },
95 | after: "Emphasis"
96 | }
97 | ],
98 | props: [
99 | styleTags({
100 | HighlightMark: defaultTags.processingInstruction,
101 | "Highlight/...": tags.highlight
102 | })
103 | ]
104 | };
105 |
106 | // Fix `inline code`, because by default it won’t surround the backticks
107 | // which would make it impossible to set background for these.
108 |
109 | const InlineCode = {
110 | props: [
111 | styleTags({
112 | "InlineCode/...": tags.inlineCode
113 | })
114 | ]
115 | };
116 |
117 | /* Export plugins */
118 |
119 | export default class MarkdownLanguage extends Extension {
120 | keys() {
121 | return markdownKeymap;
122 | }
123 |
124 | plugins() {
125 | return [
126 | markdown({
127 | base: markdownLanguage,
128 | extensions: [
129 | this.input.kirbytext ? Kirbytag(this.input.knownKirbytags) : null,
130 | Highlight,
131 | InlineCode
132 | ]
133 | })
134 | ];
135 | }
136 |
137 | // Base formats, which can be extended or overridden by their
138 | // respective toolbar buttons
139 | get syntax() {
140 | return [
141 | // Block formats
142 | {
143 | token: "FencedCode",
144 | type: "block",
145 | class: "cm-codeblock"
146 | },
147 | {
148 | token: "Blockquote",
149 | type: "block",
150 | class: "cm-blockquote",
151 | mark: /^(\s*)(>+)(\s*)/,
152 | markToken: "QuoteMark",
153 | render: "> ",
154 | multiLine: true
155 | },
156 | {
157 | token: "BulletList",
158 | type: "block",
159 | class: "cm-ol",
160 | mark: /^(\s*)([-+*])(\s+)/,
161 | markToken: "ListMark",
162 | render: "- ",
163 | multiLine: true
164 | },
165 | {
166 | token: "OrderedList",
167 | type: "block",
168 | class: "cm-ol",
169 | mark: /^(\s*)(\d+\.)(\s+)/,
170 | markToken: "ListMark",
171 | render: (n) => `${n}. `,
172 | multiLine: true
173 | },
174 | {
175 | token: "ATXHeading1",
176 | type: "block",
177 | class: "cm-heading",
178 | mark: /^(\s{0,3})(#{1})(\s+)/,
179 | markToken: "HeaderMark",
180 | render: "# ",
181 | multiLine: false
182 | },
183 | {
184 | token: "ATXHeading2",
185 | type: "block",
186 | class: "cm-heading",
187 | mark: /^(\s{0,3})(#{2})(\s+)/,
188 | markToken: "HeaderMark",
189 | render: "## ",
190 | multiLine: false
191 | },
192 | {
193 | token: "ATXHeading3",
194 | type: "block",
195 | class: "cm-heading",
196 | mark: /^(\s{0,3})(#{3})(\s+)/,
197 | markToken: "HeaderMark",
198 | render: "### ",
199 | multiLine: false
200 | },
201 | {
202 | token: "ATXHeading4",
203 | type: "block",
204 | class: "cm-heading",
205 | mark: /^(\s{0,3})(#{4})(\s+)/,
206 | markToken: "HeaderMark",
207 | render: "#### ",
208 | multiLine: false
209 | },
210 | {
211 | token: "ATXHeading5",
212 | type: "block",
213 | class: "cm-heading",
214 | mark: /^(\s{0,3})(#{5})(\s+)/,
215 | markToken: "HeaderMark",
216 | render: "##### ",
217 | multiLine: false
218 | },
219 | {
220 | token: "ATXHeading6",
221 | type: "block",
222 | class: "cm-heading",
223 | mark: /^(\s{0,3})(#{6})(\s+)/,
224 | markToken: "HeaderMark",
225 | render: "###### ",
226 | multiLine: false
227 | },
228 | {
229 | token: "HorizontalRule",
230 | type: "block",
231 | class: "cm-hr",
232 | render: "***"
233 | },
234 |
235 | // Inline formats
236 | {
237 | token: "Emphasis",
238 | type: "inline",
239 | mark: "*",
240 | markToken: "EmphasisMark",
241 | escape: true,
242 | mixable: true,
243 | expelEnclosingWhitespace: true
244 | },
245 | {
246 | token: "Highlight",
247 | type: "inline",
248 | mark: "==",
249 | markToken: "HighlightMark",
250 | escape: true,
251 | mixable: true,
252 | expelEnclosingWhitespace: true
253 | },
254 | {
255 | token: "InlineCode",
256 | type: "inline",
257 | mark: "`",
258 | markToken: "CodeMark",
259 | escape: false,
260 | mixable: false,
261 | expelEnclosingWhitespace: true
262 | },
263 | {
264 | token: "Strikethrough",
265 | type: "inline",
266 | mark: "~~",
267 | markToken: "StrikethroughMark",
268 | escape: true,
269 | mixable: true,
270 | expelEnclosingWhitespace: true
271 | },
272 | {
273 | token: "StrongEmphasis",
274 | type: "inline",
275 | mark: "**",
276 | markToken: "EmphasisMark",
277 | escape: true,
278 | mixable: true,
279 | expelEnclosingWhitespace: true
280 | },
281 | {
282 | token: "URL",
283 | type: "inline"
284 | },
285 | {
286 | token: "Kirbytag",
287 | type: "inline"
288 | }
289 | ];
290 | }
291 |
292 | get type() {
293 | return "language";
294 | }
295 | }
296 |
--------------------------------------------------------------------------------
/src/components/Utils/syntax.js:
--------------------------------------------------------------------------------
1 | import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
2 |
3 | // Get block name at given position.
4 | export function getBlockNameAt(view, blockFormats, pos) {
5 | const tree = syntaxTree(view.state);
6 | const trees = [tree.resolve(pos, -1), tree.resolve(pos, 1)];
7 |
8 | for (let n of trees) {
9 | do {
10 | if (blockFormats.exists(n.name)) {
11 | return n.name;
12 | }
13 | } while ((n = n.parent));
14 | }
15 |
16 | return "Paragraph";
17 | }
18 |
19 | export function getKirbytagAt(view, pos) {
20 | const tree = syntaxTree(view.state);
21 | const trees = [tree.resolve(pos, 0)];
22 |
23 | for (let n of trees) {
24 | do {
25 | if (n.name === "Kirbytag") {
26 | return n;
27 | }
28 | } while ((n = n.parent));
29 | }
30 |
31 | return false;
32 | }
33 |
34 | export function nodeIsKirbytag(node) {
35 | if (node.classList) {
36 | if (node.classList.contains("cm-kirbytag")) {
37 | return true;
38 | } else if (node.classList.contains("cm-line")) {
39 | return false;
40 | }
41 | }
42 | return nodeIsKirbytag(node.parentNode);
43 | }
44 |
45 | // Return all active block and inline tokens, based on current selection:
46 | // - Any block style counts as active, any of the lines touched by the selection
47 | // has this style. This can mean, that multiple block styles are active.
48 | // - Any inline style is active, if it is surrounded by the selection. Block marks
49 | // are skipped.
50 | export function getActiveTokens(
51 | view,
52 | blockFormats,
53 | inlineFormats,
54 | ensureTree = false
55 | ) {
56 | const { state } = view;
57 | const { doc } = state;
58 | const { head, from, to } = state.selection.main;
59 | const tree = ensureTree
60 | ? ensureSyntaxTree(state, to, 500)
61 | : syntaxTree(state);
62 | let tokens = [];
63 |
64 | if (from !== to) {
65 | // Selection
66 |
67 | let line = doc.lineAt(from);
68 | let n = line.number;
69 | let nFirst = line.number;
70 | let blockTokens = [];
71 | let inlineTokens = [];
72 | let inlineDone = false;
73 | let inlineTouched = [];
74 |
75 | do {
76 | let { from: lFrom, to: lTo, text } = line;
77 | let isFirstLine = n === nFirst;
78 | let lookFrom = lFrom;
79 | let lookTo = lTo - text.match(/\s*$/)[0].length; // exclude trailing whitespace
80 | let candidates = [];
81 |
82 | if (text.match(/^\s*$/)) {
83 | // skip empty and whitespace-only lines
84 | continue;
85 | }
86 |
87 | tree.iterate({
88 | enter: ({ name, from: nodeFrom, to: nodeTo }) => {
89 | let match;
90 |
91 | if (blockFormats.exists(name)) {
92 | // look for block token
93 |
94 | if (!tokens.includes(name)) {
95 | // only add block tokens, which are not already active
96 | blockTokens.push(name);
97 | }
98 |
99 | if (
100 | blockFormats.hasMark(name) &&
101 | (match = line.text.match(blockFormats.mark(name)))
102 | ) {
103 | // get block prefix (e.g. `[## ]headline`) length,
104 | // because it won’t be analyzed for inline formats
105 | lookFrom += match[0].length;
106 | }
107 |
108 | return;
109 | }
110 |
111 | if (!inlineDone) {
112 | // look from either line start or selection start, whatever
113 | // comes last
114 | lookFrom = Math.max(lookFrom, from);
115 |
116 | // look until line ending or selection ending, whatever
117 | // comes first
118 | lookTo = Math.min(lookTo, to);
119 |
120 | if (!inlineFormats.exists(name)) {
121 | // Skip tokens, which are not markup
122 | return;
123 | }
124 |
125 | if (nodeFrom <= lookFrom && nodeTo >= lookTo) {
126 | if (!candidates.includes(name)) {
127 | candidates.push(name);
128 | }
129 |
130 | if (inlineFormats.hasMark(name)) {
131 | lookFrom += inlineFormats.mark(name).length;
132 | lookTo -= inlineFormats.mark(name).length;
133 | }
134 | }
135 | }
136 | },
137 | from: lFrom,
138 | to: lTo
139 | });
140 |
141 | if (!inlineDone) {
142 | if (candidates.length === 0) {
143 | // line is not empty and does not contain any inline tokens,
144 | // stop iterating over lines and return.
145 | inlineTokens = [];
146 | inlineDone = true;
147 | }
148 |
149 | if (isFirstLine) {
150 | // The selected tokens from the first line will become the
151 | // reference for all other lines. Only tokens, which cover
152 | // all of the following lines up until selection end, will
153 | // be includes in `inlineTokens` after we’re done.
154 | inlineTokens = candidates;
155 | } else {
156 | // Inline Tokens array is filtered against candidates from
157 | // current line. Only tokens, which are present in this line
158 | // and all preceding lines are kept.
159 | inlineTokens = inlineTokens.filter((name) =>
160 | candidates.includes(name)
161 | );
162 |
163 | if (inlineTokens.length === 0) {
164 | // If no tokens are left, stop iterating.
165 | inlineDone = true;
166 | }
167 | }
168 | }
169 | } while (++n <= doc.lines && (line = doc.line(n)) && line.from < to);
170 |
171 | tokens = [...blockTokens, ...inlineTokens, ...inlineTouched];
172 | } else {
173 | // No selection
174 |
175 | tree.iterate({
176 | enter: ({ name, from: nodeFrom, to: nodeTo }) => {
177 | let inlineMatch;
178 |
179 | if (blockFormats.exists(name)) {
180 | tokens.push(name);
181 | }
182 |
183 | if (head > nodeFrom && head < nodeTo) {
184 | // Only match inline tokens, where the cursor is
185 | // inside of if (not before/after the token)
186 | inlineMatch = true;
187 | }
188 |
189 | if (inlineMatch && inlineFormats.exists(name)) {
190 | tokens.push(name);
191 | }
192 | },
193 | from,
194 | to
195 | });
196 | }
197 |
198 | // Check if selection start or end (or cursor) is inside Kirbytag,
199 | // because that is used elsewhere to disable inline format buttons.
200 | if (!tokens.includes("Kirbytag")) {
201 | let isKirbytag = !!getKirbytagAt(view, from);
202 |
203 | if (!state.selection.main.empty && !isKirbytag) {
204 | isKirbytag = getKirbytagAt(view, to);
205 | }
206 |
207 | if (isKirbytag) {
208 | tokens.push("Kirbytag");
209 | }
210 | }
211 |
212 | return tokens;
213 | }
214 |
215 | export function getCurrentInlineTokens(view, blockFormats, inlineFormats) {
216 | const { head, from, to } = view.state.selection.main;
217 | const state = view.state;
218 | const tree = syntaxTree(state);
219 | const tokens = [];
220 |
221 | // Selection spans only a single linge, get current block token and all
222 | // inline tokens
223 | tree.iterate({
224 | enter: ({ node, from: start, to: end }) => {
225 | let inlineMatch;
226 |
227 | if (from !== to) {
228 | // selection
229 | if (start <= from && to <= end) {
230 | // Matches, if selection is larger or equal to token
231 | inlineMatch = true;
232 | }
233 | } else {
234 | // no selection
235 | if (head > start && head < end) {
236 | // Only match inline tokens, where the cursor is
237 | // inside of if (not before/after the token)
238 | inlineMatch = true;
239 | }
240 | }
241 |
242 | if (inlineMatch && inlineFormats.exists(node.name)) {
243 | tokens.push({
244 | node,
245 | from: start,
246 | to: end
247 | });
248 | }
249 | },
250 | from,
251 | to
252 | });
253 |
254 | return tokens.reverse();
255 | }
256 |
--------------------------------------------------------------------------------
/src/components/Editor.js:
--------------------------------------------------------------------------------
1 | import { Compartment, EditorState } from "@codemirror/state";
2 | import {
3 | EditorView,
4 | drawSelection,
5 | placeholder,
6 | keymap
7 | } from "@codemirror/view";
8 | import { history, standardKeymap, historyKeymap } from "@codemirror/commands";
9 | import { debounce } from "underscore";
10 |
11 | import Emitter from "./Emitter.js";
12 | import { toggleBlockFormat, toggleInlineFormat } from "./Utils/markup.js";
13 | import { getActiveTokens } from "./Utils/syntax.js";
14 | import browser from "./Utils/browser.js";
15 | import URLs from "./Extensions/URLs.js";
16 | import DropCursor from "./Extensions/DropCursor.js";
17 | import FirefoxBlurFix from "./Extensions/FirefoxBlurFix.js";
18 | import Extensions from "./Extensions.js";
19 | import Invisibles from "./Extensions/Invisibles.js";
20 | import KirbytextLanguage from "./Extensions/KirbytextLanguage.js";
21 | import LineStyles from "./Extensions/LineStyles.js";
22 | import PasteUrls from "./Extensions/PasteUrls.js";
23 | import TaskLists from "./Extensions/TaskLists.js";
24 | import Theme from "./Extensions/Theme.js";
25 |
26 | // import FilePicker from "./Extensions/FilePicker.js";
27 | // import ImagePreview from "./Extensions/ImagePreview.js";
28 | // import autocomplete from "./Extensions/Autocomplete.js";
29 |
30 | const isKnownDesktopBrowser =
31 | (browser.safari || browser.chrome || browser.gecko) &&
32 | !browser.android &&
33 | !browser.ios;
34 |
35 | export default class Editor extends Emitter {
36 | constructor(value, options = {}) {
37 | super();
38 |
39 | this.activeTokens = [];
40 | this.metaKeyDown = false;
41 | this.invisibles = new Compartment();
42 |
43 | this.defaults = {
44 | readOnly: false,
45 | element: null,
46 | events: {},
47 | extensions: [],
48 | input: null,
49 | placeholder: null,
50 | invisibles: false,
51 | spellcheck: true,
52 | value: ""
53 | };
54 |
55 | this.options = {
56 | ...this.defaults,
57 | ...options
58 | };
59 |
60 | this.events = this.createEvents();
61 | this.extensions = this.createExtensions();
62 | this.inlineFormats = this.extensions.getFormats("inline");
63 | this.blockFormats = this.extensions.getFormats("block");
64 |
65 | this.buttons = this.extensions.getButtons();
66 | this.dialogs = this.extensions.getDialogs();
67 | this.view = this.createView(value);
68 | }
69 |
70 | keymap() {
71 | return keymap.of([
72 | ...standardKeymap,
73 | ...historyKeymap,
74 |
75 | // custom keymap
76 | ...this.extensions.getKeymap()
77 | ]);
78 | }
79 |
80 | createEvents() {
81 | const events = this.options.events || {};
82 |
83 | Object.entries(events).forEach(([eventName, eventCallback]) => {
84 | this.on(eventName, eventCallback);
85 | });
86 |
87 | return events;
88 | }
89 |
90 | createExtensions() {
91 | return new Extensions(
92 | [
93 | new KirbytextLanguage(),
94 | new LineStyles(),
95 | new Invisibles(),
96 | new URLs(),
97 | new PasteUrls(),
98 | new TaskLists(),
99 | new DropCursor(),
100 | new Theme(),
101 | new FirefoxBlurFix(),
102 | // new FilePicker(),
103 | // new ImagePreview(),
104 | ...this.options.extensions
105 | ],
106 | this,
107 | this.options.input
108 | );
109 | }
110 |
111 | createState(value) {
112 | const extensions = [
113 | history(),
114 | this.keymap(),
115 | ...this.extensions.getPluginsByType("language"),
116 | ...this.extensions.getPluginsByType("highlight"),
117 | ...this.extensions.getPluginsByType("button"),
118 | this.invisibles.of([]),
119 | EditorState.readOnly.of(this.options.readOnly),
120 | /**
121 | * Firefox has a known Bug, that casuses the caret to disappear,
122 | * when text is dropped into an element with contenteditable="true".
123 | * Because custom selections can cause on iOS devices and have a
124 | * performance hit, they are only activates in Firefox, to mitiage
125 | * this bug.
126 | *
127 | * See https://bugzilla.mozilla.org/show_bug.cgi?id=1327834
128 | *
129 | * However, drawn selction and custom caret look better anyways,
130 | * so enable for all known desktop browsers, where it should not
131 | * cause any trouble.
132 | */
133 | isKnownDesktopBrowser && drawSelection(),
134 | this.options.placeholder && placeholder(this.options.placeholder),
135 | this.extensions.getPluginsByType("theme"),
136 | this.extensions.getPluginsByType("extension")
137 |
138 | // autocomplete()
139 | ].filter((v) => v); // filter empty values
140 |
141 | return EditorState.create({
142 | doc: value,
143 | selection: this.state ? this.state.selection : null,
144 | extensions,
145 | tabSize: 4
146 | });
147 | }
148 |
149 | createView(value) {
150 | const debouncedUpdateActiveTokens = debounce(() => {
151 | this.activeTokens = getActiveTokens(
152 | this.view,
153 | this.blockFormats,
154 | this.inlineFormats
155 | );
156 | this.emit("active", this.activeTokens);
157 | }, 50);
158 |
159 | const view = new EditorView({
160 | state: this.createState(value),
161 | parent: this.options.element,
162 | readOnly: this.options.readOnly,
163 | dispatch: (...transaction) => {
164 | this.view.update(transaction);
165 |
166 | const value = this.view.state.doc.toString();
167 | this.emit("update", value);
168 | debouncedUpdateActiveTokens();
169 | }
170 | });
171 |
172 | // Enable spell-checking to enable browser extensions, such as Language Tool
173 | if (this.options.spellcheck) {
174 | view.contentDOM.setAttribute("spellcheck", "true");
175 | }
176 |
177 | return view;
178 | }
179 |
180 | destroy() {
181 | if (!this.view) {
182 | return;
183 | }
184 |
185 | this.view.destroy();
186 | }
187 |
188 | dispatch(transaction, emitUpdate = true) {
189 | if (emitUpdate === false) {
190 | this.emitUpdate = false;
191 | }
192 |
193 | this.view.dispatch(transaction);
194 | }
195 |
196 | focus() {
197 | if (this.view.hasFocus) {
198 | return;
199 | }
200 | this.view.focus();
201 | }
202 |
203 | getSelection() {
204 | return this.state.sliceDoc(
205 | this.state.selection.main.from,
206 | this.state.selection.main.to
207 | );
208 | }
209 |
210 | insert(text, scrollIntoView = true) {
211 | if (scrollIntoView) {
212 | this.dispatch({
213 | ...this.state.replaceSelection(text),
214 | scrollIntoView: true
215 | });
216 | } else {
217 | this.dispatch(this.state.replaceSelection(text));
218 | }
219 | }
220 |
221 | isActiveToken(...tokens) {
222 | for (let token of tokens) {
223 | if (this.activeTokens.includes(token)) {
224 | return true;
225 | }
226 | }
227 | return false;
228 | }
229 |
230 | restoreSelectionCallback() {
231 | // store selection
232 | const { anchor, head } = this.state.selection.main;
233 |
234 | // restore selection as `insert` method
235 | // depends on it
236 | return (fn) => {
237 | setTimeout(() => {
238 | this.view.dispatch({ selection: { anchor, head } });
239 |
240 | if (fn) {
241 | fn();
242 | }
243 | });
244 | };
245 | }
246 |
247 | get state() {
248 | return this.view ? this.view.state : null;
249 | }
250 |
251 | setValue(value) {
252 | this.view.dispatch({
253 | changes: {
254 | from: 0,
255 | to: this.view.state.doc.length,
256 | insert: value
257 | }
258 | });
259 | }
260 |
261 | toggleBlockFormat(type) {
262 | return toggleBlockFormat(this.view, this.blockFormats, type);
263 | }
264 |
265 | toggleInlineFormat(type) {
266 | return toggleInlineFormat(
267 | this.view,
268 | this.blockFormats,
269 | this.inlineFormats,
270 | type
271 | );
272 | }
273 |
274 | toggleInvisibles(force = null) {
275 | if (force === this.options.invisibles) {
276 | return;
277 | }
278 |
279 | this.options.invisibles =
280 | typeof force === "boolean" ? force : !this.options.invisibles;
281 | const effects = this.invisibles.reconfigure(
282 | this.options.invisibles
283 | ? this.extensions.getPluginsByType("invisibles")
284 | : []
285 | );
286 |
287 | this.dispatch({ effects });
288 | this.emit("invisibles", this.options.invisibles);
289 | }
290 |
291 | updateActiveTokens() {
292 | this.activeTokens = getActiveTokens(
293 | this.view,
294 | this.blockFormats,
295 | this.inlineFormats
296 | );
297 | }
298 |
299 | get value() {
300 | return this.view ? this.view.state.doc.toString() : "";
301 | }
302 | }
303 |
--------------------------------------------------------------------------------
/index.css:
--------------------------------------------------------------------------------
1 | .k-markdown-input-wrap{--cm-content-padding-y: .25rem;--cm-line-padding-x: var(--field-input-padding);--cm-font-size: var(--input-font-size);--cm-font-family: var(--font-mono);--cm-line-height: 1.5;--cm-code-background: rgba(0, 0, 0, .05);--cm-color-meta: var(--color-gray-500);--cm-color-light-gray: rgba(0, 0, 0, .1);--cm-selection-background: hsla(195, 80%, 40%, .16);--cm-color-special-char: #df5f5f;--cm-color-cursor: #5588ca;--cm-color-highlight-background: rgba(255, 230, 0, .4);--cm-kirbytag-background: rgba(66, 113, 174, .1);--cm-kirbytag-underline: rgba(66, 113, 174, .3);--cm-min-lines: 2}.k-markdown-input-wrap[data-font-family=sans-serif]{--cm-font-family: var(--font-sans)}.k-input[data-type=markdown][data-disabled=true]{border:var(--field-input-border)!important;box-shadow:none!important}.k-input[data-type=markdown][data-disabled=true] .cm-cursor{display:none!important}.k-markdown-input-wrap[data-size=one-line]{--cm-min-lines: 1}.k-markdown-input-wrap[data-size=two-lines]{--cm-min-lines: 2}.k-markdown-input-wrap[data-size=small]{--cm-min-lines: 4}.k-markdown-input-wrap[data-size=medium]{--cm-min-lines: 8}.k-markdown-input-wrap[data-size=large]{--cm-min-lines: 16}.k-markdown-input-wrap[data-size=huge]{--cm-min-lines: 24}.k-markdown-input .cm-line{--cm-line-indent: calc(var(--cm-indent, 0) + var(--cm-mark, 0));margin-left:var(--cm-line-indent);padding:0 var(--cm-line-padding-x);text-indent:calc(-1 * var(--cm-indent) - var(--cm-mark))}.k-markdown-input .cm-codeblock{background:var(--cm-code-background);margin-left:calc(var(--cm-line-padding-x) / 2);margin-right:calc(var(--cm-line-padding-x) / 2);padding-left:calc(var(--cm-line-padding-x) / 2);padding-right:calc(var(--cm-line-padding-x) / 2)}.k-markdown-input .cm-codeblock>*{background-color:transparent;margin:0;padding:0}.k-markdown-input .cm-blockquote{--cm-line-indent: var(--cm-indent, 0);position:relative;text-indent:0;margin-left:calc(var(--cm-line-padding-x))}.k-markdown-input .cm-blockquote:before{background:var(--cm-color-light-gray);content:"";height:100%;position:absolute;right:calc(100% + var(--cm-mark, 0) - 1.5ch);top:0;left:0;width:2px}.k-markdown-input .cm-blockquote:not([style*="--cm-mark:"]):before{right:calc(100% + var(--cm-indent, 0) - 1.5ch)}.k-markdown-input .cm-hr{display:flex!important;text-align:center}.k-markdown-input .cm-hr:before,.k-markdown-input .cm-hr:after{background:linear-gradient(var(--cm-color-light-gray),var(--cm-color-light-gray)) 50% calc(var(--cm-line-height) * 1em / 2) / 100% .0625rem no-repeat;content:"";flex:1 0 2ch}.k-markdown-input .cm-hr:before{margin-right:1ch}.k-markdown-input .cm-hr:after{margin-left:1ch}.k-markdown-input .cm-hr>*{flex-grow:0}.k-markdown-input .cm-cursor{transition:transform .15s}.k-markdown-input-wrap[data-dragover=true] .cm-cursor{transform:scale(1.1,1.5)}.k-markdown-input .cm-heading>:first-child{color:currentColor}.k-markdown-input [class*=" cm-token-"],.k-markdown-input [class^=cm-token-]{background:var(--token-background, rgba(0, 0, 0, .05));border:.0625em solid var(--token-border, rgba(0, 0, 0, .1));border-radius:.125em;color:var(--color-text, #000);margin:-.125em -.0625em;padding:.0625em 0}.k-markdown-input [class*=" cm-token-"]>*,.k-markdown-input [class^=cm-token-]>*{color:currentColor}.k-markdown-input .cm-token-red{--token-background: rgba(255, 0, 0, .12);--token-border: rgba(255, 0, 0, .25)}.k-markdown-input .cm-token-purple{--token-background: hsla(285, 44%, 50%, .17);--token-border: hsla(285, 44%, 50%, .4)}.k-markdown-input .cm-invisible-char{cursor:text}.k-markdown-input .cm-invisible-char[data-code="32"]{background-image:url("data:image/svg+xml,%3Csvg width='9' height='9' viewBox='0 0 9 9' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='4.5' cy='4.5' r='1.25' fill='%23DF5F5F'/%3E%3C/svg%3E");background-size:1ch 1ch;background-position:left center;background-repeat:repeat-x;word-break:break-all}.k-markdown-input-wrap[data-font-family=sans-serif] .cm-invisible-char[data-code="32"]{margin-left:-.25ch;margin-right:-.25ch;padding-left:.25ch;padding-right:.25ch}.k-markdown-input .cm-invisible-char[data-code="160"]{background:linear-gradient(var(--cm-color-special-char),var(--cm-color-special-char)) .0625em 100% / .0625em .125em no-repeat,linear-gradient(var(--cm-color-special-char),var(--cm-color-special-char)) .0625em 100% / calc(100% - .125em) .0625em no-repeat,linear-gradient(var(--cm-color-special-char),var(--cm-color-special-char)) calc(100% - .0625em) 100% / .0625em .125em no-repeat;color:transparent}.k-markdown-input .cm-invisible-char[data-code="173"]{border-left:.0625em solid var(--cm-color-special-char);left:.03125em;margin-left:-.0625em;position:relative}.k-markdown-input .cm-invisible-char[data-code="8203"]{background:url("data:image/svg+xml,%3Csvg width='3' height='23' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2 2.915v17.17a1.5 1.5 0 11-1 0V2.915a1.5 1.5 0 111 0zM1.5 2a.5.5 0 100-1 .5.5 0 000 1zm0 20a.5.5 0 100-1 .5.5 0 000 1z' fill='%23df5f5f' fill-rule='nonzero'/%3E%3C/svg%3E%0A") no-repeat;margin:-4px -1.5px;padding-bottom:2px;padding-left:3px;padding-top:2px}.k-markdown-input .cm-invisible-char[data-code="9"]{background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='7' viewBox='0 0 11 7'%3E%3Cpath fill='%23df5f5f' d='M9.85355339,3.14644661 C9.94403559,3.23692881 10,3.36192881 10,3.5 C10,3.63807119 9.94403559,3.76307119 9.85355339,3.85355339 L7.85355339,5.85355339 C7.65829124,6.04881554 7.34170876,6.04881554 7.14644661,5.85355339 C6.95118446,5.65829124 6.95118446,5.34170876 7.14644661,5.14644661 L8.29289322,4 L1.5,4 C1.22385763,4 1,3.77614237 1,3.5 C1,3.22385763 1.22385763,3 1.5,3 L8.29289322,3 L7.14644661,1.85355339 C6.95118446,1.65829124 6.95118446,1.34170876 7.14644661,1.14644661 C7.34170876,0.951184464 7.65829124,0.951184464 7.85355339,1.14644661 L9.85355339,3.14644661 Z'/%3E%3C/svg%3E%0A") left center no-repeat}.k-markdown-input .cm-hardbreak{position:relative}.k-markdown-input .cm-hardbreak:before{color:var(--cm-color-special-char);content:"\21a9\fe0e";display:inline-block;margin-right:-2ch;pointer-events:none;text-align:center;text-indent:0;width:2ch}.k-markdown-input .cm-taskmarker{cursor:pointer;position:relative}.k-markdown-input .cm-taskmarker.is-unchecked:hover:before{color:var(--cm-color-meta);content:"x";left:1ch;margin-right:-1ch;opacity:.7;position:relative;text-indent:0}.k-markdown-input .cm-taskmarker.is-unchecked:hover .cm-invisible-char{background:none}.k-markdown-input .cm-url{color:var(--cm-color-meta);text-decoration:underline;text-decoration-thickness:.1em;text-underline-offset:.14em}.k-markdown-input .cm-kirbytag-url{color:currentColor;text-decoration-color:var(--cm-kirbytag-underline)}:root[data-markdown-modkey=true] .k-markdown-input .cm-url,:root[data-markdown-modkey=true] .k-markdown-input .cm-url *{cursor:pointer}.k-markdown-toolbar{height:auto;min-height:38px}.k-markdown-toolbar .k-toolbar-divider{border:none;background-color:var(--toolbar-border)}.k-markdown-toolbar .k-markdown-button.is-disabled{opacity:.25;pointer-events:none}.k-markdown-input-wrap:focus-within .k-markdown-toolbar{border-bottom:1px solid rgba(0,0,0,.1);box-shadow:0 2px 5px #0000000d;color:var(--color-text);left:0;position:sticky;right:0;top:0;z-index:4}.k-markdown-input-wrap:focus-within .k-markdown-toolbar .k-markdown-button.is-active{color:#3872be}.k-markdown-input-wrap:focus-within .k-toolbar .k-markdown-button.is-active:hover{background:rgba(66,113,174,.075)}.k-markdown-toolbar .k-button-text kbd{font-variant-numeric:tabular-nums;margin-left:2.5rem;opacity:.6}.k-markdown-input .k-input-element{width:100%}.k-markdown-toolbar{height:auto;background:var(--color-white);border-start-start-radius:var(--rounded);border-start-end-radius:var(--rounded);border-bottom:1px solid var(--color-background);min-height:32px;max-width:100%;display:flex;overflow-x:auto;overflow-y:hidden}.k-markdown-toolbar-button{width:32px;height:32px}.k-markdown-toolbar-divider{width:1px;border-width:0;background:var(--color-background)}.k-markdown-toolbar .k-markdown-toolbar-button.is-disabled{opacity:.25;pointer-events:none}.k-markdown-toolbar{color:#aaa}.k-markdown-input-wrap:focus-within .k-markdown-toolbar{border-bottom:1px solid rgba(0,0,0,.1);box-shadow:0 2px 5px #0000000d;color:var(--color-text);left:0;position:sticky;right:0;top:var(--header-sticky-offset);z-index:4}.k-markdown-input-wrap .k-markdown-toolbar .k-markdown-toolbar-button:hover{background:var(--toolbar-hover)}.k-markdown-input-wrap:focus-within .k-markdown-toolbar .k-markdown-toolbar-button.is-active{color:#3872be}.k-markdown-input-wrap:focus-within .k-markdown-toolbar .k-markdown-toolbar-button.is-active:hover{background:rgba(66,113,174,.075)}.k-markdown-toolbar-button-right{border-left:1px solid var(--color-background);margin-left:auto}.k-markdown-toolbar .k-button.k-dropdown-item[aria-current=true]{color:#8fbfff}.k-markdown-toolbar .k-button-text{align-items:baseline;display:flex;justify-content:space-between}.k-markdown-toolbar .k-button-text kbd{background:hsla(0deg 0% 100% / 25%);color:#fff;font-variant-numeric:tabular-nums;margin-left:2.5rem;padding-block:2px}.k-block-container-type-markdown{padding:0}.k-block-type-markdown-input{background:none;border-radius:0;padding:0}.k-markdown-input-wrap[data-font-family=sans-serif] .cm-line{--cm-mark: 0 !important;--cm-indent: 0 !important}.k-input[data-type=markdown] .k-input-element{max-width:100%}
2 |
--------------------------------------------------------------------------------
/src/components/MarkdownInput.vue:
--------------------------------------------------------------------------------
1 |
2 |
31 |
32 |
33 |
368 |
369 |
386 |
--------------------------------------------------------------------------------
/src/syntax.css:
--------------------------------------------------------------------------------
1 | /**
2 | * ## Line Styles
3 | */
4 |
5 | /* All lines */
6 | .k-markdown-input .cm-line {
7 | --cm-line-indent: calc(var(--cm-indent, 0) + var(--cm-mark, 0));
8 |
9 | margin-left: var(--cm-line-indent);
10 | padding: 0 var(--cm-line-padding-x);
11 | text-indent: calc(-1 * var(--cm-indent) - var(--cm-mark));
12 | }
13 |
14 | /* Codeblock with background */
15 | .k-markdown-input .cm-codeblock {
16 | background: var(--cm-code-background);
17 | margin-left: calc(var(--cm-line-padding-x) / 2);
18 | margin-right: calc(var(--cm-line-padding-x) / 2);
19 | padding-left: calc(var(--cm-line-padding-x) / 2);
20 | padding-right: calc(var(--cm-line-padding-x) / 2);
21 | }
22 |
23 | /* Override styling of nested code inside code block applied by CodeMirror */
24 | .k-markdown-input .cm-codeblock > * {
25 | background-color: transparent;
26 | margin: 0;
27 | padding: 0;
28 | }
29 |
30 | /* Blockquote */
31 | .k-markdown-input .cm-blockquote {
32 | --cm-line-indent: var(--cm-indent, 0);
33 | position: relative;
34 | text-indent: 0;
35 | margin-left: calc(var(--cm-line-padding-x));
36 | }
37 |
38 | .k-markdown-input .cm-blockquote::before {
39 | background: var(--cm-color-light-gray);
40 | content: "";
41 | height: 100%;
42 | position: absolute;
43 | right: calc(100% + var(--cm-mark, 0) - 1.5ch);
44 | top: 0;
45 | left: 0;
46 | width: 2px;
47 | }
48 |
49 | .k-markdown-input .cm-blockquote:not([style*="--cm-mark:"])::before {
50 | right: calc(100% + var(--cm-indent, 0) - 1.5ch);
51 | }
52 |
53 | /* Horizontal Rule */
54 | .k-markdown-input .cm-hr {
55 | display: flex !important;
56 | text-align: center;
57 | }
58 |
59 | .k-markdown-input .cm-hr::before,
60 | .k-markdown-input .cm-hr::after {
61 | background: linear-gradient(var(--cm-color-light-gray), var(--cm-color-light-gray)) 50% calc(var(--cm-line-height) * 1em / 2) / 100% .0625rem no-repeat;
62 | content: "";
63 | flex: 1 0 2ch;
64 | }
65 |
66 | .k-markdown-input .cm-hr::before {
67 | margin-right: 1ch;
68 | }
69 |
70 | .k-markdown-input .cm-hr::after {
71 | margin-left: 1ch;
72 | }
73 |
74 | .k-markdown-input .cm-hr > * {
75 | flex-grow: 0;
76 | }
77 |
78 | .k-markdown-input .cm-cursor {
79 | transition: transform .15s;
80 | }
81 |
82 | .k-markdown-input-wrap[data-dragover="true"] .cm-cursor {
83 | transform: scale(1.1, 1.5);
84 | }
85 |
86 | /**
87 | * 1. Hack for overriding the color of header marks, because these
88 | * would appear gray otherwise, such as other `processingInstruction`
89 | * tags should. Due to CodeMirror’s language definition, these
90 | * cannot be styled separately.
91 | */
92 | .k-markdown-input .cm-heading > :first-child {
93 | color: currentColor; /* 1 */
94 | }
95 |
96 | /**
97 | * ## Inline styles
98 | */
99 |
100 | /* Custom highlights plugin */
101 |
102 | .k-markdown-input [class*=" cm-token-"],
103 | .k-markdown-input [class^="cm-token-"] {
104 | background: var(--token-background, rgba(0, 0, 0, .05));
105 | border: .0625em solid var(--token-border, rgba(0, 0, 0, .1));
106 | border-radius: .125em;
107 | color: var(--color-text, #000);
108 | margin: -.125em -.0625em;
109 | padding: .0625em 0;
110 | }
111 |
112 | .k-markdown-input [class*=" cm-token-"] > *,
113 | .k-markdown-input [class^="cm-token-"] > * {
114 | color: currentColor;
115 | }
116 |
117 | /* https://github.com/getkirby/getkirby.com/blob/master/src/scss/variables.scss */
118 | .k-markdown-input .cm-token-red {
119 | --token-background: rgba(255, 0, 0, .12);
120 | --token-border: rgba(255, 0, 0, .25);
121 | }
122 |
123 | .k-markdown-input .cm-token-purple {
124 | --token-background: hsla(285, 44%, 50%, .17);
125 | --token-border: hsla(285, 44%, 50%, .4);
126 | }
127 |
128 | /**
129 | * Special chars
130 | */
131 |
132 | .k-markdown-input .cm-invisible-char {
133 | cursor: text;
134 | }
135 |
136 | /**
137 | * 1- or more Spaces
138 | * 1. Ensure, that extra span around each space character does not
139 | * have any effect on word-wrapping.
140 | **/
141 | .k-markdown-input .cm-invisible-char[data-code="32"] {
142 | background-image: url("data:image/svg+xml,%3Csvg width='9' height='9' viewBox='0 0 9 9' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='4.5' cy='4.5' r='1.25' fill='%23DF5F5F'/%3E%3C/svg%3E");
143 | background-size: 1ch 1ch;
144 | background-position: left center;
145 | background-repeat: repeat-x;
146 |
147 | /* background:
148 | radial-gradient(
149 | circle at center,
150 | var(--cm-color-special-char) .1em,
151 | transparent .1em
152 | )
153 | left calc(50% + .0625em) / 1ch 1ch repeat-x; */
154 | word-break: break-all; /* 1 */
155 | }
156 | /*
157 | .k-markdown-input .cm-invisible-char[data-code="32"]::after {
158 | content: "\200C";
159 | } */
160 |
161 | /**
162 | * 1. Sans-serif mode needs a bit more attention, because the spaces
163 | * are much narrower, than in monospace mode. Otherwise, the "dot"
164 | * would not render correctly.
165 | */
166 | .k-markdown-input-wrap[data-font-family="sans-serif"] .cm-invisible-char[data-code="32"] {
167 | margin-left: -.25ch; /* 1 */
168 | margin-right: -.25ch; /* 1 */
169 | padding-left: .25ch; /* 1 */
170 | padding-right: .25ch; /* 1 */
171 | }
172 |
173 | /* No-Break Space */
174 | .k-markdown-input .cm-invisible-char[data-code="160"] {
175 | background:
176 | linear-gradient(var(--cm-color-special-char), var(--cm-color-special-char)) .0625em 100% / .0625em .125em no-repeat,
177 | linear-gradient(var(--cm-color-special-char), var(--cm-color-special-char)) .0625em 100% / calc(100% - .125em) .0625em no-repeat,
178 | linear-gradient(var(--cm-color-special-char), var(--cm-color-special-char)) calc(100% - .0625em) 100% / .0625em .125em no-repeat;
179 | color: transparent;
180 | }
181 |
182 | /* Soft Hyphen */
183 | .k-markdown-input .cm-invisible-char[data-code="173"] {
184 | border-left: .0625em solid var(--cm-color-special-char);
185 | left: .03125em;
186 | margin-left: -.0625em;
187 | position: relative;
188 | }
189 |
190 | /* Zero-Width Space */
191 | .k-markdown-input .cm-invisible-char[data-code="8203"] {
192 | background: url("data:image/svg+xml,%3Csvg width='3' height='23' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2 2.915v17.17a1.5 1.5 0 11-1 0V2.915a1.5 1.5 0 111 0zM1.5 2a.5.5 0 100-1 .5.5 0 000 1zm0 20a.5.5 0 100-1 .5.5 0 000 1z' fill='%23df5f5f' fill-rule='nonzero'/%3E%3C/svg%3E%0A") no-repeat;
193 | margin-bottom: -4px;
194 | margin-left: -1.5px;
195 | margin-right: -1.5px;
196 | margin-top: -4px;
197 | padding-bottom: 2px;
198 | padding-left: 3px;
199 | padding-top: 2px;
200 | }
201 |
202 | /* Tab character */
203 | .k-markdown-input .cm-invisible-char[data-code="9"] {
204 | background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='7' viewBox='0 0 11 7'%3E%3Cpath fill='%23df5f5f' d='M9.85355339,3.14644661 C9.94403559,3.23692881 10,3.36192881 10,3.5 C10,3.63807119 9.94403559,3.76307119 9.85355339,3.85355339 L7.85355339,5.85355339 C7.65829124,6.04881554 7.34170876,6.04881554 7.14644661,5.85355339 C6.95118446,5.65829124 6.95118446,5.34170876 7.14644661,5.14644661 L8.29289322,4 L1.5,4 C1.22385763,4 1,3.77614237 1,3.5 C1,3.22385763 1.22385763,3 1.5,3 L8.29289322,3 L7.14644661,1.85355339 C6.95118446,1.65829124 6.95118446,1.34170876 7.14644661,1.14644661 C7.34170876,0.951184464 7.65829124,0.951184464 7.85355339,1.14644661 L9.85355339,3.14644661 Z'/%3E%3C/svg%3E%0A") left center no-repeat;
205 | }
206 |
207 | /* Hardbreak (2 consecutive spaces or more at end of line */
208 | .k-markdown-input .cm-hardbreak {
209 | position: relative;
210 | }
211 |
212 | /**
213 | * 1. Unset text-indent, set by line styles.
214 | */
215 | .k-markdown-input .cm-hardbreak::before {
216 | color: var(--cm-color-special-char);
217 | content: "\21A9\FE0E"; /* LEFTWARDS ARROW WITH HOOK */
218 | display: inline-block;
219 | margin-right: -2ch;
220 | pointer-events: none;
221 | text-align: center;
222 | text-indent: 0; /* 1 */
223 | width: 2ch;
224 | }
225 |
226 | /**
227 | * ## Extensions
228 | */
229 |
230 | .k-markdown-input .cm-taskmarker {
231 | cursor: pointer;
232 | position: relative;
233 | }
234 |
235 | .k-markdown-input .cm-taskmarker.is-unchecked:hover::before {
236 | color: var(--cm-color-meta);
237 | content: "x";
238 | left: 1ch;
239 | margin-right: -1ch;
240 | opacity: .7;
241 | position: relative;
242 | text-indent: 0;
243 | }
244 |
245 | .k-markdown-input .cm-taskmarker.is-unchecked:hover .cm-invisible-char {
246 | background: none;
247 | }
248 |
249 | .k-markdown-input .cm-url {
250 | color: var(--cm-color-meta);
251 | text-decoration: underline;
252 | text-decoration-thickness: .1em;
253 | text-underline-offset: .14em;
254 | }
255 |
256 | .k-markdown-input .cm-kirbytag-url {
257 | color: currentColor;
258 | text-decoration-color: var(--cm-kirbytag-underline);
259 | }
260 |
261 | :root[data-markdown-modkey="true"] .k-markdown-input .cm-url,
262 | :root[data-markdown-modkey="true"] .k-markdown-input .cm-url * {
263 | cursor: pointer;
264 | }
265 |
266 | .k-block-container-type-markdown {
267 | padding: 0;
268 | }
269 | .k-block-type-markdown-input {
270 | background: none;
271 | border-radius: 0;
272 | padding: 0;
273 | }
274 |
275 | .k-markdown-toolbar {
276 | height: auto;
277 | min-height: 38px;
278 | }
279 | .k-markdown-toolbar .k-toolbar-divider {
280 | border: none;
281 | background-color: var(--toolbar-border);
282 | }
283 |
284 | /* disabled state of toolbar buttons */
285 | .k-markdown-toolbar .k-markdown-button.is-disabled {
286 | opacity: 0.25;
287 | pointer-events: none;
288 | }
289 |
290 | /* Editor has focus */
291 | .k-markdown-input-wrap:focus-within .k-markdown-toolbar {
292 | border-bottom: 1px solid rgba(0, 0, 0, 0.1);
293 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
294 | color: var(--color-text);
295 | left: 0;
296 | position: sticky;
297 | right: 0;
298 | top: 0;
299 | z-index: 4;
300 | }
301 | .k-markdown-input-wrap:focus-within .k-markdown-toolbar .k-markdown-button.is-active {
302 | color: #3872be;
303 | }
304 | .k-markdown-input-wrap:focus-within .k-toolbar .k-markdown-button.is-active:hover {
305 | background: rgba(66, 113, 174, 0.075);
306 | }
307 |
308 | /* Align invisibles button to the right of the toolbar */
309 | .k-markdown-toolbar-button-right {
310 | border-left: 1px solid var(--color-background);
311 | margin-left: auto;
312 | }
313 |
314 | /** Active state for dropdown items */
315 | .k-markdown-toolbar .k-button.k-dropdown-item[aria-current="true"] {
316 | color: #8fbfff;
317 | }
318 | .k-markdown-toolbar .k-button-text {
319 | align-items: baseline;
320 | display: flex;
321 | justify-content: space-between;
322 | }
323 | .k-markdown-toolbar .k-button-text kbd {
324 | font-variant-numeric: tabular-nums;
325 | margin-left: 2.5rem;
326 | opacity: 0.6;
327 | }
328 |
329 |
330 |
331 |
332 |
333 |
334 |
335 |
336 |
337 |
338 |
339 |
340 |
341 |
342 |
343 |
344 |
345 |
346 |
347 |
348 |
349 |
350 |
351 |
352 |
353 |
354 |
355 |
356 |
357 |
358 |
359 |
360 |
361 |
362 |
363 |
364 |
365 |
366 |
367 |
368 |
369 |
370 |
371 |
372 |
373 |
374 |
375 |
376 |
377 |
378 |
379 |
380 |
381 |
382 |
383 |
384 |
385 |
386 |
387 |
388 |
389 |
390 |
391 |
392 |
393 |
394 |
395 |
396 |
397 |
398 |
399 |
400 |
401 |
402 |
403 |
404 |
405 |
406 |
407 |
408 |
409 |
410 |
411 |
412 |
413 |
414 |
415 |
416 |
417 |
418 |
419 |
420 |
421 |
422 |
423 |
424 |
425 |
426 |
427 |
428 |
429 |
430 |
431 |
432 |
433 |
434 |
435 |
436 |
437 |
438 |
439 |
440 |
441 |
442 |
443 |
444 |
445 |
446 |
447 |
448 |
449 |
450 |
451 |
452 |
453 |
454 |
455 |
456 |
457 |
458 |
459 |
460 |
461 |
462 |
463 |
464 |
465 |
466 |
467 |
468 |
469 |
470 |
471 |
472 |
473 |
474 |
475 |
476 |
477 |
478 |
479 |
480 |
481 |
482 |
483 |
484 |
485 |
486 |
487 |
488 |
489 |
490 |
491 |
492 |
493 |
494 |
495 |
496 |
497 |
498 |
499 |
500 |
501 |
502 |
503 |
504 |
505 |
506 |
507 |
508 |
509 |
510 |
511 |
512 |
513 |
514 |
515 |
516 |
517 |
518 |
519 |
520 |
521 |
522 |
523 |
524 |
525 |
526 |
527 |
528 |
529 |
530 |
531 |
532 |
533 |
534 |
535 |
536 |
537 |
538 |
539 |
540 |
541 |
542 |
543 |
544 |
545 |
546 |
547 |
548 |
549 |
550 |
551 |
552 |
553 |
554 |
555 |
556 |
557 |
558 |
559 |
560 |
561 |
562 |
563 |
564 |
565 |
566 |
567 |
568 |
569 |
570 |
571 |
572 |
573 |
574 |
575 |
576 |
577 |
578 |
579 |
580 |
581 |
582 |
583 |
584 |
585 |
586 |
587 |
588 |
589 |
590 |
591 |
592 |
593 |
594 |
595 |
596 |
597 |
598 |
599 |
600 |
601 |
602 |
603 |
604 |
605 |
606 |
607 |
608 |
609 |
610 |
611 |
612 |
613 |
614 |
615 |
616 |
617 |
618 |
619 |
620 |
621 |
622 |
623 |
624 |
625 |
626 |
627 |
628 |
629 |
630 |
631 |
632 |
633 |
634 |
635 |
636 |
637 |
638 |
639 |
640 |
641 |
642 |
643 |
644 |
645 |
646 |
647 |
648 |
649 |
650 |
651 |
652 |
653 |
654 |
655 |
656 |
657 |
658 |
659 |
660 |
661 |
662 |
663 |
664 |
665 |
666 |
667 |
668 |
669 |
670 |
671 |
672 |
673 |
674 |
675 |
676 |
677 |
678 |
679 |
680 |
681 |
682 |
683 |
684 |
685 |
686 |
687 |
688 |
689 |
690 |
691 |
692 |
693 |
694 |
695 |
696 |
697 |
698 |
699 |
700 |
701 |
702 |
703 |
704 |
705 |
706 |
707 |
708 |
709 |
710 |
711 |
712 |
713 |
714 |
715 |
716 |
717 |
718 | /**
719 | * General field setup
720 | */
721 | .k-markdown-input-wrap[data-font-family="sans-serif"] .cm-line {
722 | --cm-mark: 0 !important;
723 | --cm-indent: 0 !important;
724 | }
725 |
726 | /**
727 | * 1. Make sure there's no overflow
728 | */
729 | .k-markdown-input .k-input-element {
730 | width: 100%; /* 1 */
731 | }
732 |
733 |
--------------------------------------------------------------------------------