├── .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 | 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 | 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 | [![CI Status](https://flat.badgen.net/github/checks/getkirby/composer-installer/master)](https://github.com/getkirby/composer-installer/actions?query=workflow%3ACI) 4 | [![Coverage Status](https://flat.badgen.net/coveralls/c/github/getkirby/composer-installer)](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 | 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 | 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 | --------------------------------------------------------------------------------