├── .env ├── .eslintignore ├── .eslintrc ├── .github ├── actions │ ├── build-core │ │ └── action.yml │ ├── build-svelte │ │ └── action.yml │ ├── build-vue │ │ └── action.yml │ └── install-dependencies │ │ └── action.yml └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .npmrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── env.d.ts ├── examples ├── demo.ts ├── hooks.test.ts ├── hooks.ts ├── minimal.test.ts ├── minimal.ts ├── plugins.tsx ├── template-ts │ ├── .gitignore │ ├── index.html │ ├── package.json │ ├── public │ │ └── vite.svg │ ├── src │ │ ├── main.ts │ │ └── vite-env.d.ts │ └── tsconfig.json └── web-component.ts ├── index.html ├── lib └── codemirror-kit │ ├── decorations.ts │ ├── index.ts │ ├── markdown.ts │ └── parsers.ts ├── package.json ├── playwright.config.ts ├── plugins └── katex │ ├── grammar.ts │ └── index.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── screenshot.png ├── src ├── api │ ├── destroy.ts │ ├── focus.ts │ ├── format.ts │ ├── get_doc.ts │ ├── index.ts │ ├── insert.ts │ ├── load.ts │ ├── options.ts │ ├── reconfigure.ts │ ├── select.ts │ ├── selections.ts │ ├── update.ts │ └── wrap.ts ├── constants.ts ├── editor │ ├── adapters │ │ └── selections.ts │ ├── extensions │ │ ├── appearance.ts │ │ ├── autocomplete.ts │ │ ├── blockquote.ts │ │ ├── code.ts │ │ ├── extension.ts │ │ ├── images.ts │ │ ├── indentWithTab.ts │ │ ├── ink.ts │ │ ├── line_wrapping.ts │ │ ├── lists.ts │ │ ├── placeholder.ts │ │ ├── readonly.ts │ │ ├── search.tsx │ │ ├── spellcheck.ts │ │ ├── theme.ts │ │ └── vim.ts │ ├── index.ts │ ├── state.ts │ └── view.ts ├── extensions.ts ├── index.tsx ├── instance.ts ├── markdown.ts ├── store.ts ├── ui │ ├── app.tsx │ ├── components │ │ ├── button │ │ │ └── index.tsx │ │ ├── details │ │ │ └── index.tsx │ │ ├── drop_zone │ │ │ ├── index.tsx │ │ │ └── styles.css │ │ ├── editor │ │ │ └── index.tsx │ │ ├── root │ │ │ ├── index.tsx │ │ │ ├── styles.css │ │ │ └── styles.tsx │ │ └── toolbar │ │ │ ├── index.tsx │ │ │ └── styles.css │ └── utils.ts └── utils │ ├── awaitable.ts │ ├── inspect.ts │ ├── merge.ts │ ├── options.ts │ ├── queue.ts │ └── readability.ts ├── svelte ├── .eslintignore ├── .eslintrc.cjs ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── README.md ├── package.json ├── src │ ├── app.d.ts │ ├── app.html │ ├── lib │ │ ├── InkMde.svelte │ │ └── index.js │ └── routes │ │ └── +page.svelte ├── static │ └── favicon.png ├── svelte.config.js ├── tsconfig.json └── vite.config.js ├── test ├── assets │ └── example.md ├── e2e │ ├── index.html │ └── plugins │ │ └── autocomplete.spec.ts ├── helpers │ └── dom.ts ├── mocks │ ├── editor.ts │ └── store.ts └── unit │ └── src │ ├── api │ └── index.test.ts │ └── index.test.ts ├── tsconfig.json ├── types.config.js ├── types ├── index.ts ├── ink.ts ├── internal.ts ├── ui.ts └── values.ts ├── vite.config.ts └── vue ├── dev ├── app.ts ├── client.ts └── server.ts ├── env.d.ts ├── index.html ├── server.ts ├── src ├── InkMde.vue └── index.ts ├── tsconfig.json ├── types.config.js └── vite.config.ts /.env: -------------------------------------------------------------------------------- 1 | VITE_SSR= 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | svelte 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["artisan", "plugin:solid/typescript"], 3 | "plugins": ["solid"], 4 | "rules": { 5 | "solid/components-return-once": ["off", {}] 6 | }, 7 | "overrides": [ 8 | { 9 | "files": ["./**/*.md/*.ts"], 10 | "rules": { 11 | "@typescript-eslint/no-unused-vars": ["off"] 12 | } 13 | }, 14 | { 15 | "files": ["./src/index.tsx"], 16 | "rules": { 17 | "eslint-comments/no-unlimited-disable": ["off", {}] 18 | } 19 | }, 20 | { 21 | "files": ["./types/**/*"], 22 | "rules": { 23 | "@typescript-eslint/no-namespace": ["off"] 24 | } 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /.github/actions/build-core/action.yml: -------------------------------------------------------------------------------- 1 | name: Build core 2 | description: Build the core package 3 | runs: 4 | using: composite 5 | steps: 6 | - shell: bash 7 | run: pnpm core:build 8 | -------------------------------------------------------------------------------- /.github/actions/build-svelte/action.yml: -------------------------------------------------------------------------------- 1 | name: Build Svelte 2 | description: Build the Svelte wrapper 3 | runs: 4 | using: composite 5 | steps: 6 | - uses: ./.github/actions/build-core 7 | - shell: bash 8 | run: pnpm svelte:build 9 | -------------------------------------------------------------------------------- /.github/actions/build-vue/action.yml: -------------------------------------------------------------------------------- 1 | name: Build Vue 2 | description: Build the Vue wrapper 3 | runs: 4 | using: composite 5 | steps: 6 | - uses: ./.github/actions/build-core 7 | - shell: bash 8 | run: pnpm vue:build 9 | -------------------------------------------------------------------------------- /.github/actions/install-dependencies/action.yml: -------------------------------------------------------------------------------- 1 | name: Install dependencies 2 | description: Install with cached dependencies 3 | runs: 4 | using: composite 5 | steps: 6 | - uses: actions/setup-node@v4 7 | with: 8 | node-version: 18 9 | - uses: pnpm/action-setup@v4 10 | with: 11 | run_install: false 12 | - shell: bash 13 | run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 14 | - uses: actions/cache@v3 15 | with: 16 | key: v1-${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 17 | path: ${{ env.STORE_PATH }} 18 | restore-keys: v1-${{ runner.os }}-pnpm-store- 19 | - shell: bash 20 | run: pnpm install 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | build-core: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: ./.github/actions/install-dependencies 13 | - uses: ./.github/actions/build-core 14 | build-svelte: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: ./.github/actions/install-dependencies 19 | - uses: ./.github/actions/build-core 20 | - uses: ./.github/actions/build-svelte 21 | build-vue: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v3 25 | - uses: ./.github/actions/install-dependencies 26 | - uses: ./.github/actions/build-core 27 | - uses: ./.github/actions/build-vue 28 | check-docs: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v3 32 | - uses: ./.github/actions/install-dependencies 33 | - run: pnpm -r docs:check 34 | lint: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v3 38 | - uses: ./.github/actions/install-dependencies 39 | - run: pnpm -r lint 40 | test-e2e: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v3 44 | - uses: ./.github/actions/install-dependencies 45 | - run: pnpm playwright install --with-deps 46 | - run: pnpm -r test:e2e 47 | test-unit: 48 | runs-on: ubuntu-latest 49 | steps: 50 | - uses: actions/checkout@v3 51 | - uses: ./.github/actions/install-dependencies 52 | - run: pnpm -r test:unit 53 | typecheck: 54 | runs-on: ubuntu-latest 55 | steps: 56 | - uses: actions/checkout@v3 57 | - uses: ./.github/actions/install-dependencies 58 | - uses: ./.github/actions/build-core 59 | - run: pnpm typecheck 60 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | on: 3 | workflow_dispatch: 4 | release: 5 | types: [published] 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: ./.github/actions/install-dependencies 12 | - uses: ./.github/actions/build-core 13 | - uses: ./.github/actions/build-svelte 14 | - uses: ./.github/actions/build-vue 15 | - run: pnpm config set '//registry.npmjs.org/:_authToken' ${{ secrets.NPM_PUBLISH_TOKEN }} 16 | - run: pnpm publish --access public --no-git-checks 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | dist 4 | node_modules 5 | tmp 6 | 7 | # Playwright 8 | /test-results/ 9 | /playwright-report/ 10 | /blob-report/ 11 | /playwright/.cache/ 12 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-workspace-root-check=true 2 | include-workspace-root=true 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "arcanis.vscode-zipfs" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2022 David R. Myers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMeta { 4 | readonly env: ImportMetaEnv 5 | } 6 | 7 | interface ImportMetaEnv { 8 | readonly VITE_SSR: boolean 9 | } 10 | -------------------------------------------------------------------------------- /examples/demo.ts: -------------------------------------------------------------------------------- 1 | import { ink } from '/src/index' 2 | import example from '/test/assets/example.md?raw' 3 | import { type Instance, type Values } from '/types/ink' 4 | 5 | declare global { 6 | interface Window { 7 | ink: Instance, 8 | // theme helpers 9 | auto: () => void, 10 | dark: () => void, 11 | light: () => void, 12 | } 13 | } 14 | 15 | const url = new URL(window.location.href) 16 | const doc = url.searchParams.get('doc') ?? example 17 | 18 | window.ink = ink(document.getElementById('app')!, { 19 | doc, 20 | files: { 21 | clipboard: true, 22 | dragAndDrop: true, 23 | handler: (files) => { 24 | // eslint-disable-next-line no-console 25 | console.log({ files }) 26 | 27 | const lastFile = Array.from(files).pop() 28 | 29 | if (lastFile) { 30 | return URL.createObjectURL(lastFile) 31 | } 32 | }, 33 | injectMarkup: true, 34 | }, 35 | hooks: { 36 | afterUpdate: (text) => { 37 | url.searchParams.set('doc', text) 38 | 39 | window.history.replaceState(null, '', url) 40 | }, 41 | }, 42 | interface: { 43 | images: true, 44 | readonly: false, 45 | spellcheck: true, 46 | toolbar: true, 47 | }, 48 | lists: true, 49 | placeholder: 'Start typing...', 50 | readability: true, 51 | toolbar: { 52 | upload: true, 53 | }, 54 | }) 55 | 56 | window.ink.focus() 57 | 58 | const toggleTheme = (theme: Values.Appearance) => { 59 | document.documentElement.classList.remove('auto', 'dark', 'light') 60 | document.documentElement.classList.add(theme) 61 | 62 | window.ink.reconfigure({ interface: { appearance: theme } }) 63 | } 64 | 65 | window.auto = toggleTheme.bind(undefined, 'auto') 66 | window.dark = toggleTheme.bind(undefined, 'dark') 67 | window.light = toggleTheme.bind(undefined, 'light') 68 | 69 | toggleTheme('light') 70 | -------------------------------------------------------------------------------- /examples/hooks.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { mockAll } from '/test/helpers/dom' 3 | 4 | describe('hooks', () => { 5 | it('runs without errors', async () => { 6 | mockAll() 7 | 8 | await expect(import('./hooks')).resolves.not.toThrow() 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /examples/hooks.ts: -------------------------------------------------------------------------------- 1 | import { defineOptions, ink } from 'ink-mde' 2 | 3 | // With hooks, you can keep your state in sync with the editor. 4 | const state = { doc: '# Start with some text' } 5 | 6 | // Use defineOptions for automatic type hinting. 7 | const options = defineOptions({ 8 | doc: state.doc, 9 | hooks: { 10 | afterUpdate: (doc: string) => { 11 | state.doc = doc 12 | }, 13 | }, 14 | }) 15 | 16 | const editor = ink(document.getElementById('editor')!, options) 17 | 18 | // You can also update the editor directly. 19 | editor.update(state.doc) 20 | -------------------------------------------------------------------------------- /examples/minimal.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { mockAll } from '/test/helpers/dom' 3 | 4 | describe('minimal', () => { 5 | it('runs without errors', async () => { 6 | mockAll() 7 | 8 | await expect(import('./minimal')).resolves.not.toThrow() 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /examples/minimal.ts: -------------------------------------------------------------------------------- 1 | import { ink } from 'ink-mde' 2 | 3 | // The only requirement is an HTML element. 4 | ink(document.getElementById('editor')!) 5 | -------------------------------------------------------------------------------- /examples/plugins.tsx: -------------------------------------------------------------------------------- 1 | import { ink, plugin } from 'ink-mde' 2 | import { buildBlockWidgetDecoration, buildWidget, nodeDecorator } from '/lib/codemirror-kit' 3 | import { katex } from '/plugins/katex' 4 | 5 | ink(document.querySelector('#app')!, { 6 | doc: '# Start with some text\n\nThis is some \$inline math\$\n\n\$\$\nc = \\pm\\sqrt{a^2 + b^2}\n\$\$\n\n```\nhi\n```\n\n```\nhello\n```', 7 | katex: true, 8 | placeholder: 'This is a really long block of text... This is a really long block of text... This is a really long block of text... This is a really long block of text... This is a really long block of text... This is a really long block of text...', 9 | plugins: [ 10 | plugin({ 11 | value: async () => { 12 | return nodeDecorator({ 13 | nodes: ['FencedCode'], 14 | onMatch: (state, node) => { 15 | const text = state.sliceDoc(node.from, node.to).split('\n').slice(1, -1).join('\n') 16 | 17 | if (text) { 18 | return buildBlockWidgetDecoration({ 19 | widget: buildWidget({ 20 | // You can see the results of optimization when there is no id specified. Because CodeMirror has no way of knowing 21 | // whether the decoration matches, it usually has to rebuild the DOM on each change. With the optimization setting, 22 | // the DOM only has to be rebuilt if the changes overlap with the existing decoration. 23 | // 24 | // With optimization enabled, the updated timestamp only updates when something inside that block changes. 25 | // With optimization disabled, the updated timestamp updates on *any* doc change. 26 | // 27 | // id: text, 28 | toDOM: () => { 29 | return ( 30 |
31 |
DOM updated at: {Date.now()}
32 | {text} 33 |
34 | ) as HTMLElement 35 | }, 36 | }), 37 | }) 38 | } 39 | }, 40 | // When set to true, only the nodes that overlap changed ranges will be reprocessed. 41 | optimize: true, 42 | }) 43 | }, 44 | }), 45 | katex(), 46 | ], 47 | }) 48 | -------------------------------------------------------------------------------- /examples/template-ts/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /examples/template-ts/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/template-ts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "template-ts", 3 | "type": "module", 4 | "version": "0.0.0", 5 | "private": true, 6 | "root": true, 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "ink-mde": "workspace:*" 14 | }, 15 | "devDependencies": { 16 | "typescript": "^5.3.3", 17 | "vite": "^5.0.10" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/template-ts/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/template-ts/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ink } from 'ink-mde' 2 | 3 | const targetElement = document.getElementById('editor') 4 | 5 | ink(targetElement!, { 6 | doc: 'Hello, world!', 7 | }) 8 | -------------------------------------------------------------------------------- /examples/template-ts/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/template-ts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true 21 | }, 22 | "include": ["src"] 23 | } 24 | -------------------------------------------------------------------------------- /examples/web-component.ts: -------------------------------------------------------------------------------- 1 | import { ink } from 'ink-mde' 2 | import { LitElement, html } from 'lit' 3 | 4 | class InkMde extends LitElement { 5 | firstUpdated() { 6 | ink(this.renderRoot.querySelector('#editor')!, { 7 | // eslint-disable-next-line @typescript-eslint/quotes 8 | doc: "#examples\n\n# A Quick Guide to the Web Crypto API\n\nThe documentation on [MDN](https://developer.mozilla.org/en-US/) is robust, but it requires a lot of jumping around to individual method APIs. I hope this article is helpful for anyone out there looking for guidance.\n\n### Generating a Key\n\nTo start things off, we need to generate a symmetric key.\n\n```js\n// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/generateKey\nconst generateKey = async () => {\n return window.crypto.subtle.generateKey({\n name: 'AES-GCM',\n length: 256,\n }, true, ['encrypt', 'decrypt'])\n}\n```\n\n### Encoding Data\n\nBefore we can encrypt data, we first have to encode it into a byte stream. We can achieve this pretty simply with the `TextEncoder` class. This little utility will be used by our `encrypt` function later.\n\n```js\n// https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder\nconst encode = (data) => {\n const encoder = new TextEncoder()\n \n return encoder.encode(data)\n}\n```\n\n### Generating an Initialization Vector (IV)\n\nSimply put, an IV is what introduces true randomness into our encryption strategy. When using the same key to encrypt multiple sets of data, it is possible to derive relationships between the encrypted chunks of the cipher and therefore expose some or all of the original message. IVs ensure that repeating character sequences in the input data produce varying byte sequences in the resulting cipher. It is perfectly safe to store IVs in plain text alongside our encrypted message, and we will need to do this to decrypt our message later.\n\n```js\n// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues\nconst generateIv = () => {\n // https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams\n return window.crypto.getRandomValues(new Uint8Array(12))\n}\n```\n\nWe never want to use the same IV with a given key, so it's best to incorporate automatic IV generation into our encryption strategy as we will do later.\n\n### Encrypting Data\n\nNow that we have all of our utilities in place, we can implement our `encrypt` function! As mentioned above, we will need it to return both the cipher _and_ the IV so that we can decrypt the cipher later.\n\n```js\n// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/encrypt\nconst encrypt = async (data, key) => {\n const encoded = encode(data)\n const iv = generateIv()\n const cipher = await window.crypto.subtle.encrypt({\n name: 'AES-GCM',\n iv: iv,\n }, key, encoded)\n \n return {\n cipher,\n iv,\n }\n}\n```\n\n## Transmission and Storage\n\nMost practical applications of encryption involve the transmission or storage of said encrypted data. When data is encrypted using SubtleCrypto, the resulting cipher and IV are represented as raw binary data buffers. This is not an ideal format for transmission or storage, so we will tackle packing and unpacking next.\n\n### Packing Data\n\nSince data is often transmitted in JSON and stored in databases, it makes sense to pack our data in a format that is portable. We are going to convert our binary data buffers into base64-encoded strings. Depending on your use case, the base64 encoding is absolutely optional, but I find it helps make the data as portable as you could possibly need.\n\n```js \n// https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String\nconst pack = (buffer) => {\n return window.btoa(\n String.fromCharCode.apply(null, new Uint8Array(buffer))\n )\n}\n```\n\n### Unpacking Data\n\nOnce our packed data has been transmitted, stored, and later retrieved, we just need to reverse the process. We will convert our base64-encoded strings back into raw binary buffers.\n\n```js\n// https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String\nconst unpack = (packed) => {\n const string = window.atob(packed)\n const buffer = new ArrayBuffer(string.length)\n const bufferView = new Uint8Array(buffer)\n\n for (let i = 0; i < string.length; i++) {\n bufferView[i] = string.charCodeAt(i)\n }\n\n return buffer\n}\n```\n\n## Decryption\n\nWe're in the home stretch! The last step of the process is decrypting our data to see those sweet, sweet secrets. As with unpacking, we just need to reverse the encryption process.\n\n### Decoding Data\n\nAfter decrypting, we will need to decode our resulting byte stream back into its original form. We can achieve this with the `TextDecoder` class. This utility will be used by our `decrypt` function later.\n\n```js\n// https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder\nconst decode = (bytestream) => {\n const decoder = new TextDecoder()\n \n return decoder.decode(bytestream)\n}\n```\n\n### Decrypting Data\n\nNow we just need to implement the `decrypt` function. As mentioned before, we will need to supply not just the key but also the IV that was used in the encryption step.\n\n```js\n// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/decrypt\nconst decrypt = async (cipher, key, iv) => {\n const encoded = await window.crypto.subtle.decrypt({\n name: 'AES-GCM',\n iv: iv,\n }, key, cipher)\n \n return decode(encoded)\n}\n```\n\n## Putting it into Practice\n\nLet's write an app! Now that all of our utilities are built, we just need to use them. We will encrypt, pack, and transmit our data to a secure endpoint. Then, we will retrieve, unpack, and decrypt the original message.\n\n```js\nconst app = async () => {\n // encrypt message\n const first = 'Hello, World!'\n const key = await generateKey()\n const { cipher, iv } = await encrypt(first, key)\n \n // pack and transmit\n await fetch('/secure-api', {\n method: 'POST',\n body: JSON.stringify({\n cipher: pack(cipher),\n iv: pack(iv),\n }),\n })\n \n // retrieve\n const response = await fetch('/secure-api').then(res => res.json())\n\n // unpack and decrypt message\n const final = await decrypt(unpack(response.cipher), key, unpack(response.iv))\n console.log(final) // logs 'Hello, World!'\n}\n```\n\nThat's all there is to it! We have successfully implemented client-side encryption.\n\nAs a final note, I just want to share [octo](https://github.com/voraciousdev/octo), a note-taking app for developers, one more time. It's free, it's open source, and I would absolutely love it if you checked it out. Thanks, everyone, and happy coding. ✌️", 9 | hooks: { 10 | afterUpdate: (doc) => { 11 | // eslint-disable-next-line no-console 12 | console.log(JSON.stringify(doc)) 13 | }, 14 | }, 15 | }) 16 | } 17 | 18 | render() { 19 | return html`
` 20 | } 21 | } 22 | 23 | customElements.define('ink-mde', InkMde) 24 | 25 | document.querySelector('#app')!.innerHTML = '' 26 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ink-mde 7 | 8 | 9 | 10 | 11 | 30 | 31 | 32 |
33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /lib/codemirror-kit/decorations.ts: -------------------------------------------------------------------------------- 1 | import { syntaxTree } from '@codemirror/language' 2 | import { type EditorState, type Range, type RangeCursor, RangeSet, StateField, type Transaction } from '@codemirror/state' 3 | import { Decoration, EditorView, type WidgetType } from '@codemirror/view' 4 | import { type SyntaxNodeRef } from '@lezer/common' 5 | 6 | // Todo: Maybe open a PR to expose these types. 7 | // https://github.com/codemirror/view/blob/3f1b991f3db20d152045ae9e6872466fc8d8fdac/src/decoration.ts 8 | export type LineDecorationSpec = { attributes?: { [key: string]: string }, class?: string, [other: string]: any } 9 | export type MarkDecorationSpec = { attributes?: { [key: string]: string }, class?: string, inclusive?: boolean, inclusiveEnd?: boolean, inclusiveStart?: boolean, tagName?: string, [other: string]: any } 10 | export type ReplaceDecorationSpec = { block?: boolean, inclusive?: boolean, inclusiveEnd?: boolean, inclusiveStart?: boolean, widget?: WidgetType, [other: string]: any } 11 | export type WidgetDecorationSpec = { widget: WidgetType, block?: boolean, side?: number, [other: string]: any } 12 | 13 | export type Defined = Required<{ 14 | [K in keyof T]: NonNullable 15 | }> 16 | 17 | // Custom types. 18 | export type CustomDecorationArgs = Parameters[0] 19 | export type CustomDecoration = T & Decoration 20 | export type CustomDecorationTypes = 'line' | 'mark' | 'replace' | 'widget' 21 | export type CustomWidget = T & WidgetSpec 22 | export type CustomWidgetArgs = { 23 | [K in keyof T]?: K extends 'eq' ? (other: CustomWidget>) => boolean : T[K] 24 | } 25 | export type CustomWidgetOptions = { 26 | [K in keyof T]: K extends 'compare' | 'eq' ? (other: CustomWidget>) => boolean : T[K] 27 | } 28 | export type CustomWidgetDecoration = T & WidgetDecoration & Decoration 29 | export type CustomWidgetDecorationArgs = WidgetDecorationSpec & Record 30 | export type NodeBlockDecoration = CustomWidgetDecoration & { widget: { node: SyntaxNodeRef } } 31 | export type NodeDecoratorArgs = { 32 | nodes: string[], 33 | onMatch: (state: EditorState, node: SyntaxNodeRef) => T | T[] | void, 34 | optimize?: boolean, 35 | range?: { 36 | from?: number, 37 | to?: number, 38 | }, 39 | } 40 | export type PartialWidgetSpec = Partial 41 | export type TypedDecoration = Decoration & { spec: Decoration['spec'] & { type: CustomDecorationTypes } } 42 | export type WidgetSpec = WidgetType & { id?: string } 43 | export type WidgetDecoration = { block: boolean, side: number, widget: CustomWidget } 44 | 45 | export const buildBlockWidgetDecoration = (options: T) => { 46 | return buildWidgetDecoration({ 47 | block: true, 48 | side: -1, 49 | ...options, 50 | }) 51 | } 52 | 53 | export const buildLineDecoration = (options: T) => { 54 | return Decoration.line({ 55 | ...options, 56 | type: 'line', 57 | }) as CustomDecoration 58 | } 59 | 60 | export const buildMarkDecoration = (options: T) => { 61 | return Decoration.mark({ 62 | ...options, 63 | type: 'mark', 64 | }) as CustomDecoration 65 | } 66 | 67 | export type WidgetOptions> = { 68 | [K in ((keyof T) | 'compare' | 'eq')]?: K extends 'compare' | 'eq' ? (other: WidgetReturn) => boolean 69 | : K extends keyof WidgetSpec ? WidgetSpec[K] 70 | : T[K] 71 | } 72 | export type WidgetReturn> = { 73 | [K in keyof (T & WidgetSpec)]: K extends keyof T ? NonNullable 74 | : K extends keyof WidgetSpec ? WidgetSpec[K] 75 | : never 76 | } 77 | 78 | export const buildWidget = >(options: WidgetOptions): WidgetSpec => { 79 | const eq = (other: WidgetReturn) => { 80 | if (options.eq) return options.eq(other) 81 | if (!options.id) return false 82 | 83 | return options.id === other.id 84 | } 85 | 86 | return { 87 | compare: (other: WidgetReturn) => { 88 | return eq(other) 89 | }, 90 | coordsAt: () => null, 91 | destroy: () => {}, 92 | eq: (other: WidgetReturn) => { 93 | return eq(other) 94 | }, 95 | estimatedHeight: -1, 96 | ignoreEvent: () => true, 97 | lineBreaks: 0, 98 | toDOM: () => { 99 | return document.createElement('span') 100 | }, 101 | updateDOM: () => false, 102 | ...options, 103 | } 104 | } 105 | 106 | export const buildWidgetDecoration = (options: T): CustomWidgetDecoration => { 107 | return Decoration.widget({ 108 | block: false, 109 | side: 0, 110 | ...options, 111 | widget: buildWidget({ 112 | ...options.widget, 113 | }), 114 | type: 'widget', 115 | }) as CustomWidgetDecoration 116 | } 117 | 118 | export const buildNodeDecorations = (state: EditorState, options: NodeDecoratorArgs) => { 119 | const decorationRanges: Range>[] = [] 120 | 121 | syntaxTree(state).iterate({ 122 | enter: (node) => { 123 | if (options.nodes.includes(node.type.name)) { 124 | const maybeDecorations = options.onMatch(state, node) 125 | 126 | if (!maybeDecorations) return 127 | 128 | const decorations = Array().concat(maybeDecorations) 129 | 130 | decorations.forEach((decoration) => { 131 | if (decoration.spec.type === 'line') { 132 | const wrapped = buildLineDecoration({ ...decoration.spec, node: { ...node } }) 133 | 134 | for (let line = state.doc.lineAt(node.from); line.from < node.to; line = state.doc.lineAt(line.to + 1)) { 135 | decorationRanges.push(wrapped.range(line.from)) 136 | 137 | if (line.to === state.doc.length) break 138 | } 139 | } 140 | 141 | if (decoration.spec.type === 'mark') { 142 | const wrapped = buildMarkDecoration({ ...decoration.spec, node: { ...node } }).range(node.from, node.to) 143 | 144 | decorationRanges.push(wrapped) 145 | } 146 | 147 | if (decoration.spec.type === 'widget') { 148 | const wrapped = buildWidgetDecoration({ ...decoration.spec, node: { ...node } }).range(node.from) 149 | 150 | decorationRanges.push(wrapped) 151 | } 152 | }) 153 | } 154 | }, 155 | from: options.range?.from, 156 | to: options.range?.to, 157 | }) 158 | 159 | return decorationRanges.sort((left, right) => { 160 | return left.from - right.from 161 | }) 162 | } 163 | 164 | export const buildOptimizedNodeDecorations = (rangeSet: RangeSet>, transaction: Transaction, options: NodeDecoratorArgs) => { 165 | const decorations = [] as Range>[] 166 | const cursor = rangeSet.iter() 167 | const cursors = [] as RangeCursor>[] 168 | const cursorsToSkip = [] as RangeCursor>[] 169 | 170 | while (cursor.value) { 171 | cursors.push({ ...cursor }) 172 | cursor.next() 173 | } 174 | 175 | transaction.changes.iterChangedRanges((_beforeFrom, _beforeTo, changeFrom, changeTo) => { 176 | cursors.forEach((cursor) => { 177 | if (cursor.value) { 178 | const nodeLength = cursor.value.spec.node.to - cursor.value.spec.node.from 179 | const cursorFrom = cursor.from 180 | const cursorTo = cursor.from + nodeLength 181 | 182 | if (isOverlapping(cursorFrom, cursorTo, changeFrom, changeTo)) { 183 | cursorsToSkip.push(cursor) 184 | } 185 | } 186 | }) 187 | 188 | const range = { from: changeFrom, to: changeTo } 189 | 190 | decorations.push(...buildNodeDecorations(transaction.state, { ...options, range })) 191 | }) 192 | 193 | const cursorDecos = cursors.filter(cursor => !cursorsToSkip.includes(cursor)).flatMap((cursor) => { 194 | const range = cursor.value?.range(cursor.from) as Range> 195 | 196 | if (!range) return [] 197 | 198 | return [range] 199 | }) 200 | 201 | decorations.push(...cursorDecos) 202 | 203 | const allDecorations = decorations.sort((left, right) => { 204 | return left.from - right.from 205 | }) 206 | 207 | // This reprocesses the entire state. 208 | return allDecorations 209 | } 210 | 211 | export const isOverlapping = (x1: number, x2: number, y1: number, y2: number) => { 212 | return Math.max(x1, y1) <= Math.min(x2, y2) 213 | } 214 | 215 | export const nodeDecorator = (options: NodeDecoratorArgs) => { 216 | return StateField.define>>({ 217 | create(state) { 218 | return RangeSet.of(buildNodeDecorations(state, options)) 219 | }, 220 | update(rangeSet, transaction) { 221 | // Reconfiguration and state effects will reprocess the entire state to ensure nothing is missed. 222 | if (transaction.reconfigured || transaction.effects.length > 0) { 223 | return RangeSet.of(buildNodeDecorations(transaction.state, options)) 224 | } 225 | 226 | const updatedRangeSet = rangeSet.map(transaction.changes) 227 | 228 | if (transaction.docChanged) { 229 | // Only process the ranges that are affected by this change. 230 | if (options.optimize) { 231 | return RangeSet.of(buildOptimizedNodeDecorations(updatedRangeSet, transaction, options)) 232 | } 233 | 234 | return RangeSet.of(buildNodeDecorations(transaction.state, options)) 235 | } 236 | 237 | // No need to redecorate. Instead, just map the decorations through the transaction changes. 238 | return updatedRangeSet 239 | }, 240 | provide(field) { 241 | // Provide the extension to the editor. 242 | return EditorView.decorations.from(field) 243 | }, 244 | }) 245 | } 246 | -------------------------------------------------------------------------------- /lib/codemirror-kit/index.ts: -------------------------------------------------------------------------------- 1 | export * from './decorations' 2 | export * from './markdown' 3 | export * from './parsers' 4 | -------------------------------------------------------------------------------- /lib/codemirror-kit/markdown.ts: -------------------------------------------------------------------------------- 1 | import { type Tag, tags } from '@lezer/highlight' 2 | import { type BlockParser, type DelimiterType, type InlineParser, type MarkdownConfig, type NodeSpec } from '@lezer/markdown' 3 | import { buildTag, getCharCode } from './parsers' 4 | 5 | export const buildMarkNode = (markName: string) => { 6 | return buildTaggedNode(markName, [tags.processingInstruction]) 7 | } 8 | 9 | export const buildTaggedNode = (nodeName: string, styleTags: Tag[] = []) => { 10 | const tag = buildTag() 11 | const node = defineNode({ 12 | name: nodeName, 13 | style: [tag, ...styleTags], 14 | }) 15 | 16 | return { 17 | node, 18 | tag, 19 | } 20 | } 21 | 22 | export const defineBlockParser = (options: T) => options 23 | export const defineDelimiter = (options?: T) => ({ ...options }) 24 | export const defineInlineParser = (options: T) => options 25 | export const defineMarkdown = (options: T) => options 26 | export const defineNode = (options: T) => options 27 | 28 | /** 29 | * Build an inline Markdown parser that matches a custom expression. E.g. `[[hello]]` for wikilinks. 30 | * 31 | * @param options 32 | * @param options.name The name of the new node type. E.g. `WikiLink` for wikilinks. 33 | * @param options.prefix The tokens that indicate the start of the custom expression. E.g. `[[` for wikilinks. 34 | * @param options.suffix The tokens that indicate the end of the custom expression. E.g. `]]` for wikilinks. 35 | * @param options.matcher The regex that matches the custom expression. E.g. `/(?\[\[)(?.*?)(?\]\])/` for wikilinks. 36 | */ 37 | export const buildInlineParser = (options: { name: string, prefix: string, matcher?: RegExp, suffix?: string }) => { 38 | const { name, prefix, suffix } = options 39 | const matcher = options.matcher || new RegExp(`(?${prefix})(?.*?)(?${suffix})`) 40 | const [firstCharCode, ...prefixCharCodes] = prefix.split('').map(getCharCode) 41 | 42 | const taggedNode = buildTaggedNode(name) 43 | const taggedNodeMark = buildMarkNode(`${name}Mark`) 44 | const taggedNodeMarkStart = buildMarkNode(`${name}MarkStart`) 45 | const taggedNodeMarkEnd = buildMarkNode(`${name}MarkEnd`) 46 | 47 | return defineInlineParser({ 48 | name: taggedNode.node.name, 49 | parse: (inlineContext, nextCharCode, position) => { 50 | // Match the first char code as efficiently as possible. 51 | if (nextCharCode !== firstCharCode) return -1 52 | 53 | // Check the remaining char codes. 54 | for (let i = 0; i < prefixCharCodes.length; i++) { 55 | const prefixCharCode = prefixCharCodes[i] 56 | const nextCharPosition = position + i + 1 57 | 58 | if (inlineContext.text.charCodeAt(nextCharPosition) !== prefixCharCode) return -1 59 | } 60 | 61 | // Get all the line text left after the current position. 62 | const remainingLineText = inlineContext.slice(position, inlineContext.end) 63 | 64 | if (!matcher.test(remainingLineText)) return -1 65 | 66 | const match = remainingLineText.match(matcher) 67 | 68 | // Todo: Add config to allow empty? 69 | if (!match?.groups?.content) return -1 70 | 71 | const prefixLength = prefix.length 72 | const contentLength = match.groups.content.length 73 | const suffixLength = suffix?.length || 0 74 | 75 | return inlineContext.addElement( 76 | inlineContext.elt( 77 | taggedNode.node.name, 78 | position, 79 | position + prefixLength + contentLength + suffixLength, 80 | [ 81 | inlineContext.elt( 82 | taggedNodeMark.node.name, 83 | position, 84 | position + prefixLength, 85 | [ 86 | inlineContext.elt( 87 | taggedNodeMarkStart.node.name, 88 | position, 89 | position + prefixLength, 90 | ), 91 | ], 92 | ), 93 | inlineContext.elt( 94 | taggedNodeMark.node.name, 95 | position + prefixLength + contentLength, 96 | position + prefixLength + contentLength + suffixLength, 97 | [ 98 | inlineContext.elt( 99 | taggedNodeMarkEnd.node.name, 100 | position + prefixLength + contentLength, 101 | position + prefixLength + contentLength + suffixLength, 102 | ), 103 | ], 104 | ), 105 | ], 106 | ), 107 | ) 108 | }, 109 | }) 110 | } 111 | -------------------------------------------------------------------------------- /lib/codemirror-kit/parsers.ts: -------------------------------------------------------------------------------- 1 | import { Tag } from '@lezer/highlight' 2 | 3 | export const buildTag = (parent?: T) => { 4 | return Tag.define(parent) 5 | } 6 | 7 | export const getCharCode = (char: string) => { 8 | return char.charCodeAt(0) 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ink-mde", 3 | "type": "module", 4 | "version": "0.34.0", 5 | "packageManager": "pnpm@9.1.0+sha256.22e36fba7f4880ecf749a5ca128b8435da085ecd49575e7fb9e64d6bf4fad394", 6 | "description": "A beautiful, modern, customizable Markdown editor powered by CodeMirror 6 and TypeScript.", 7 | "author": "David R. Myers ", 8 | "license": "MIT", 9 | "funding": "https://github.com/sponsors/davidmyersdev", 10 | "homepage": "https://github.com/davidmyersdev/ink-mde", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/davidmyersdev/ink-mde.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/davidmyersdev/ink-mde/issues" 17 | }, 18 | "keywords": [ 19 | "codemirror", 20 | "component", 21 | "easymde", 22 | "javascript", 23 | "js", 24 | "markdown", 25 | "mde", 26 | "octo", 27 | "sfc", 28 | "simplemde", 29 | "ts", 30 | "typescript", 31 | "vue", 32 | "vue3" 33 | ], 34 | "sideEffects": false, 35 | "exports": { 36 | ".": { 37 | "browser": { 38 | "types": "./dist/client.d.ts", 39 | "require": "./dist/client.cjs", 40 | "import": "./dist/client.js" 41 | }, 42 | "node": { 43 | "types": "./dist/index.d.ts", 44 | "require": "./dist/index.cjs", 45 | "import": "./dist/index.js" 46 | }, 47 | "types": "./dist/client.d.ts", 48 | "require": "./dist/client.cjs", 49 | "import": "./dist/client.js" 50 | }, 51 | "./svelte": { 52 | "types": "./svelte/dist/index.d.ts", 53 | "import": "./svelte/dist/index.js" 54 | }, 55 | "./vue": { 56 | "browser": { 57 | "types": "./vue/dist/client.d.ts", 58 | "require": "./vue/dist/client.cjs", 59 | "import": "./vue/dist/client.js" 60 | }, 61 | "node": { 62 | "types": "./vue/dist/index.d.ts", 63 | "require": "./vue/dist/index.cjs", 64 | "import": "./vue/dist/index.js" 65 | }, 66 | "types": "./vue/dist/client.d.ts", 67 | "require": "./vue/dist/client.cjs", 68 | "import": "./vue/dist/client.js" 69 | } 70 | }, 71 | "browser": { 72 | "./dist/index.cjs": "./dist/client.cjs", 73 | "./dist/index.js": "./dist/client.js" 74 | }, 75 | "main": "./dist/index.cjs", 76 | "module": "./dist/index.js", 77 | "types": "./dist/index.d.ts", 78 | "files": [ 79 | "dist", 80 | "dist/**/*", 81 | "svelte/dist", 82 | "svelte/dist/**/*", 83 | "svelte/package.json", 84 | "vue/dist", 85 | "vue/dist/**/*" 86 | ], 87 | "scripts": { 88 | "build": "run-s core:build && run-p svelte:build vue:build", 89 | "ci": "run-p ci:*", 90 | "ci:build": "run-s build", 91 | "ci:lint": "run-s lint", 92 | "ci:docs": "run-s docs:check", 93 | "ci:dedupe": "pnpm dedupe --use-stderr", 94 | "core:build": "run-s core:clean && run-p core:build:*", 95 | "core:build:client": "vite build", 96 | "core:build:server": "VITE_SSR=1 vite build --ssr", 97 | "core:build:types": "run-s core:typecheck && rollup -c types.config.js", 98 | "core:clean": "rimraf ./dist ./tmp", 99 | "core:dev": "vite", 100 | "core:typecheck": "tsc", 101 | "dev": "run-s core:dev", 102 | "docs:check": "embedme --verify README.md", 103 | "docs:diff": "embedme --stdout README.md", 104 | "docs:update": "embedme README.md", 105 | "lint": "eslint .", 106 | "lint:fix": "eslint --fix .", 107 | "prepack": "run-s build", 108 | "svelte:build": "run-s svelte:clean && pnpm --filter svelte build", 109 | "svelte:clean": "rimraf ./svelte/dist", 110 | "test": "run-s test:watch", 111 | "test:e2e": "playwright test", 112 | "test:unit": "vitest run", 113 | "test:watch": "vitest", 114 | "typecheck": "run-p core:typecheck vue:typecheck", 115 | "vue:build": "run-s vue:clean && run-p vue:typecheck vue:build:*", 116 | "vue:build:client": "vite build -c ./vue/vite.config.ts", 117 | "vue:build:server": "VITE_SSR=1 vite build -c ./vue/vite.config.ts --ssr", 118 | "vue:build:types": "run-s vue:typecheck && rollup -c ./vue/types.config.js", 119 | "vue:clean": "rimraf ./vue/dist", 120 | "vue:dev": "tsx ./vue/server.ts", 121 | "vue:typecheck": "vue-tsc --project ./vue/tsconfig.json" 122 | }, 123 | "peerDependencies": { 124 | "svelte": "^3.0.0 || ^4.0.0", 125 | "vue": "^3.0.0" 126 | }, 127 | "peerDependenciesMeta": { 128 | "svelte": { 129 | "optional": true 130 | }, 131 | "vue": { 132 | "optional": true 133 | } 134 | }, 135 | "dependencies": { 136 | "@codemirror/autocomplete": "^6.18.1", 137 | "@codemirror/commands": "^6.6.2", 138 | "@codemirror/lang-markdown": "^6.3.0", 139 | "@codemirror/language": "^6.10.3", 140 | "@codemirror/language-data": "^6.5.1", 141 | "@codemirror/search": "^6.5.6", 142 | "@codemirror/state": "^6.4.1", 143 | "@codemirror/view": "^6.34.1", 144 | "@lezer/common": "^1.2.1", 145 | "@lezer/highlight": "^1.2.1", 146 | "@lezer/markdown": "^1.3.1", 147 | "@replit/codemirror-vim": "^6.2.1", 148 | "katex": "^0.16.9", 149 | "solid-js": "^1.8.7", 150 | "style-mod": "^4.1.2" 151 | }, 152 | "devDependencies": { 153 | "@playwright/test": "^1.42.1", 154 | "@rollup/plugin-alias": "^5.1.0", 155 | "@types/express": "^4.17.21", 156 | "@types/katex": "^0.16.7", 157 | "@types/node": "^20.10.6", 158 | "@vitejs/plugin-vue": "^5.0.2", 159 | "@vue/tsconfig": "^0.5.1", 160 | "embedme": "github:davidmyersdev/embedme#live-fork", 161 | "eslint": "^8.56.0", 162 | "eslint-config-artisan": "^0.3.0", 163 | "eslint-plugin-solid": "^0.13.1", 164 | "express": "^4.18.2", 165 | "jsdom": "^23.0.1", 166 | "lit": "^3.1.2", 167 | "npm-run-all": "^4.1.5", 168 | "rimraf": "^5.0.5", 169 | "rollup": "^4.9.2", 170 | "rollup-plugin-dts": "^6.1.0", 171 | "tsx": "^4.7.0", 172 | "typescript": "~5.3.3", 173 | "vite": "^5.0.10", 174 | "vite-plugin-externalize-deps": "^0.8.0", 175 | "vite-plugin-solid": "2.8.0", 176 | "vitest": "^1.1.1", 177 | "vue": "^3.4.3", 178 | "vue-tsc": "^1.8.27" 179 | }, 180 | "publishConfig": { 181 | "access": "public" 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test' 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: './test/e2e', 14 | /* Run tests in files in parallel */ 15 | fullyParallel: true, 16 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 17 | forbidOnly: !!process.env.CI, 18 | /* Retry on CI only */ 19 | retries: process.env.CI ? 2 : 0, 20 | /* Opt out of parallel tests on CI. */ 21 | workers: process.env.CI ? 1 : undefined, 22 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 23 | reporter: 'html', 24 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 25 | use: { 26 | /* Base URL to use in actions like `await page.goto('/')`. */ 27 | baseURL: process.env.BASE_URL || 'http://localhost:5173', 28 | 29 | testIdAttribute: 'data-test-id', 30 | 31 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 32 | trace: 'on-first-retry', 33 | video: 'retain-on-failure', 34 | }, 35 | 36 | /* Configure projects for major browsers */ 37 | projects: [ 38 | { 39 | name: 'chromium', 40 | use: { ...devices['Desktop Chrome'] }, 41 | }, 42 | 43 | { 44 | name: 'firefox', 45 | use: { ...devices['Desktop Firefox'] }, 46 | }, 47 | 48 | { 49 | name: 'webkit', 50 | use: { ...devices['Desktop Safari'] }, 51 | }, 52 | 53 | /* Test against mobile viewports. */ 54 | // { 55 | // name: 'Mobile Chrome', 56 | // use: { ...devices['Pixel 5'] }, 57 | // }, 58 | // { 59 | // name: 'Mobile Safari', 60 | // use: { ...devices['iPhone 12'] }, 61 | // }, 62 | 63 | /* Test against branded browsers. */ 64 | // { 65 | // name: 'Microsoft Edge', 66 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 67 | // }, 68 | // { 69 | // name: 'Google Chrome', 70 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 71 | // }, 72 | ], 73 | 74 | /* Run your local dev server before starting the tests */ 75 | webServer: { 76 | command: 'pnpm dev', 77 | url: 'http://localhost:5173', 78 | reuseExistingServer: !process.env.CI, 79 | }, 80 | }) 81 | -------------------------------------------------------------------------------- /plugins/katex/grammar.ts: -------------------------------------------------------------------------------- 1 | import { buildMarkNode, buildTaggedNode, defineBlockParser, defineInlineParser, defineMarkdown, getCharCode } from '/lib/codemirror-kit' 2 | 3 | export const charCodes = { 4 | dollarSign: getCharCode('$'), 5 | } 6 | 7 | export const mathInlineTestRegex = /\$.*?\$/ 8 | export const mathInlineCaptureRegex = /\$(?.*?)\$/ 9 | 10 | export const mathInline = buildTaggedNode('MathInline') 11 | export const mathInlineMark = buildMarkNode('MathInlineMark') 12 | export const mathInlineMarkOpen = buildMarkNode('MathInlineMarkOpen') 13 | export const mathInlineMarkClose = buildMarkNode('MathInlineMarkClose') 14 | export const mathInlineParser = defineInlineParser({ 15 | name: mathInline.node.name, 16 | parse: (inlineContext, nextCharCode, position) => { 17 | // Not a "$" char. 18 | if (nextCharCode !== charCodes.dollarSign) return -1 19 | 20 | const remainingLineText = inlineContext.slice(position, inlineContext.end) 21 | 22 | // No ending "$" found. 23 | if (!mathInlineTestRegex.test(remainingLineText)) return -1 24 | 25 | const match = remainingLineText.match(mathInlineCaptureRegex) 26 | 27 | // No match found. 28 | if (!match?.groups?.math) return -1 29 | 30 | const mathExpressionLength = match.groups.math.length 31 | 32 | return inlineContext.addElement( 33 | inlineContext.elt( 34 | mathInline.node.name, 35 | position, 36 | position + mathExpressionLength + 2, 37 | [ 38 | inlineContext.elt( 39 | mathInlineMark.node.name, 40 | position, 41 | position + 1, 42 | [ 43 | inlineContext.elt( 44 | mathInlineMarkOpen.node.name, 45 | position, 46 | position + 1, 47 | ), 48 | ], 49 | ), 50 | inlineContext.elt( 51 | mathInlineMark.node.name, 52 | position + mathExpressionLength + 1, 53 | position + mathExpressionLength + 2, 54 | [ 55 | inlineContext.elt( 56 | mathInlineMarkClose.node.name, 57 | position + mathExpressionLength + 1, 58 | position + mathExpressionLength + 2, 59 | ), 60 | ], 61 | ), 62 | ], 63 | ), 64 | ) 65 | }, 66 | }) 67 | 68 | export const mathBlockTestRegex = /\$.*?\$/ 69 | export const mathBlockCaptureRegex = /\$(?.*?)\$/ 70 | 71 | export const mathBlock = buildTaggedNode('MathBlock') 72 | export const mathBlockMark = buildMarkNode('MathBlockMark') 73 | export const mathBlockMarkOpen = buildMarkNode('MathBlockMarkOpen') 74 | export const mathBlockMarkClose = buildMarkNode('MathBlockMarkClose') 75 | export const mathBlockParser = defineBlockParser({ 76 | name: 'MathBlock', 77 | parse: (blockContext, line) => { 78 | // Not "$" 79 | if (line.next !== charCodes.dollarSign) return false 80 | // Not "$$" 81 | if (line.text.charCodeAt(line.pos + 1) !== charCodes.dollarSign) return false 82 | 83 | const openLineStart = blockContext.lineStart + line.pos 84 | const openLineEnd = openLineStart + line.text.length 85 | 86 | // Move past opening line. 87 | while (blockContext.nextLine()) { 88 | // Closing "$$" 89 | if (line.next === charCodes.dollarSign && line.text.charCodeAt(line.pos + 1) === charCodes.dollarSign) { 90 | const closeLineStart = blockContext.lineStart + line.pos 91 | const closeLineEnd = closeLineStart + line.text.length 92 | 93 | blockContext.addElement( 94 | blockContext.elt( 95 | mathBlock.node.name, 96 | openLineStart, 97 | closeLineEnd, 98 | [ 99 | blockContext.elt( 100 | mathBlockMark.node.name, 101 | openLineStart, 102 | openLineEnd, 103 | [ 104 | blockContext.elt( 105 | mathBlockMarkOpen.node.name, 106 | openLineStart, 107 | openLineEnd, 108 | ), 109 | ], 110 | ), 111 | blockContext.elt( 112 | mathBlockMark.node.name, 113 | closeLineStart, 114 | closeLineEnd, 115 | [ 116 | blockContext.elt( 117 | mathBlockMarkClose.node.name, 118 | closeLineStart, 119 | closeLineEnd, 120 | ), 121 | ], 122 | ), 123 | ], 124 | ), 125 | ) 126 | 127 | blockContext.nextLine() 128 | 129 | break 130 | } 131 | } 132 | 133 | return true 134 | }, 135 | }) 136 | 137 | export const grammar = defineMarkdown({ 138 | defineNodes: [ 139 | mathInline.node, 140 | mathInlineMark.node, 141 | mathInlineMarkClose.node, 142 | mathInlineMarkOpen.node, 143 | mathBlock.node, 144 | mathBlockMark.node, 145 | mathBlockMarkOpen.node, 146 | mathBlockMarkClose.node, 147 | ], 148 | parseBlock: [ 149 | mathBlockParser, 150 | ], 151 | parseInline: [ 152 | mathInlineParser, 153 | ], 154 | }) 155 | -------------------------------------------------------------------------------- /plugins/katex/index.ts: -------------------------------------------------------------------------------- 1 | import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' 2 | import { EditorView } from '@codemirror/view' 3 | import { buildBlockWidgetDecoration, buildLineDecoration, buildWidget, nodeDecorator } from '/lib/codemirror-kit' 4 | import { plugin, pluginTypes } from '/src/index' 5 | import { grammar, mathInline, mathInlineMark, mathInlineMarkClose, mathInlineMarkOpen } from './grammar' 6 | 7 | export const katex = () => { 8 | return [ 9 | plugin({ 10 | key: 'katex', 11 | type: pluginTypes.grammar, 12 | value: async () => grammar, 13 | }), 14 | plugin({ 15 | key: 'katex', 16 | value: async () => { 17 | return nodeDecorator({ 18 | nodes: ['MathBlock', 'MathBlockMarkClose', 'MathBlockMarkOpen'], 19 | onMatch: (_state, node) => { 20 | const classes = ['ink-mde-line-math-block'] 21 | 22 | if (node.name === 'MathBlockMarkOpen') classes.push('ink-mde-line-math-block-open') 23 | if (node.name === 'MathBlockMarkClose') classes.push('ink-mde-line-math-block-close') 24 | 25 | return buildLineDecoration({ 26 | attributes: { 27 | class: classes.join(' '), 28 | }, 29 | }) 30 | }, 31 | optimize: false, 32 | }) 33 | }, 34 | }), 35 | plugin({ 36 | key: 'katex', 37 | value: async () => { 38 | return nodeDecorator({ 39 | nodes: ['MathBlock'], 40 | onMatch: (state, node) => { 41 | const text = state.sliceDoc(node.from, node.to).split('\n').slice(1, -1).join('\n') 42 | 43 | if (text) { 44 | return buildBlockWidgetDecoration({ 45 | widget: buildWidget({ 46 | id: text, 47 | toDOM: (view) => { 48 | const container = document.createElement('div') 49 | const katexTarget = document.createElement('div') 50 | 51 | container.className = 'ink-mde-block-widget-container' 52 | katexTarget.className = 'ink-mde-block-widget ink-mde-katex-target' 53 | container.appendChild(katexTarget) 54 | 55 | import('katex').then(({ default: lib }) => { 56 | lib.render(text, katexTarget, { output: 'html', throwOnError: false }) 57 | 58 | view.requestMeasure() 59 | }) 60 | 61 | return container 62 | }, 63 | updateDOM: (dom, view) => { 64 | const katexTarget = dom.querySelector('.ink-mde-katex-target') 65 | 66 | if (katexTarget) { 67 | import('katex').then(({ default: lib }) => { 68 | lib.render(text, katexTarget, { output: 'html', throwOnError: false }) 69 | 70 | view.requestMeasure() 71 | }) 72 | 73 | return true 74 | } 75 | 76 | return false 77 | }, 78 | }), 79 | }) 80 | } 81 | }, 82 | optimize: false, 83 | }) 84 | }, 85 | }), 86 | plugin({ 87 | key: 'katex', 88 | value: async () => { 89 | return syntaxHighlighting( 90 | HighlightStyle.define([ 91 | { 92 | tag: [mathInline.tag, mathInlineMark.tag], 93 | backgroundColor: 'var(--ink-internal-block-background-color)', 94 | }, 95 | { 96 | tag: [mathInlineMarkClose.tag], 97 | backgroundColor: 'var(--ink-internal-block-background-color)', 98 | borderRadius: '0 var(--ink-internal-border-radius) var(--ink-internal-border-radius) 0', 99 | paddingRight: 'var(--ink-internal-inline-padding)', 100 | }, 101 | { 102 | tag: [mathInlineMarkOpen.tag], 103 | backgroundColor: 'var(--ink-internal-block-background-color)', 104 | borderRadius: 'var(--ink-internal-border-radius) 0 0 var(--ink-internal-border-radius)', 105 | paddingLeft: 'var(--ink-internal-inline-padding)', 106 | }, 107 | ]), 108 | ) 109 | }, 110 | }), 111 | plugin({ 112 | key: 'katex', 113 | value: async () => { 114 | return EditorView.theme({ 115 | '.ink-mde-line-math-block': { 116 | backgroundColor: 'var(--ink-internal-block-background-color)', 117 | padding: '0 var(--ink-internal-block-padding) !important', 118 | }, 119 | '.ink-mde-line-math-block-open': { 120 | borderRadius: 'var(--ink-internal-border-radius) var(--ink-internal-border-radius) 0 0', 121 | }, 122 | '.ink-mde-line-math-block-close': { 123 | borderRadius: '0 0 var(--ink-internal-border-radius) var(--ink-internal-border-radius)', 124 | }, 125 | }) 126 | }, 127 | }), 128 | ] 129 | } 130 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - . 3 | - ./examples/* 4 | - ./svelte 5 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmyersdev/ink-mde/920c3eef5e6645899ef1583ecfc8a9de25d0e64a/screenshot.png -------------------------------------------------------------------------------- /src/api/destroy.ts: -------------------------------------------------------------------------------- 1 | import type InkInternal from '/types/internal' 2 | 3 | export const destroy = ([state]: InkInternal.Store) => { 4 | const { editor } = state() 5 | 6 | editor.destroy() 7 | } 8 | -------------------------------------------------------------------------------- /src/api/focus.ts: -------------------------------------------------------------------------------- 1 | import type InkInternal from '/types/internal' 2 | 3 | export const focus = ([state]: InkInternal.Store) => { 4 | const { editor } = state() 5 | 6 | if (!editor.hasFocus) { 7 | editor.focus() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/api/get_doc.ts: -------------------------------------------------------------------------------- 1 | import type InkInternal from '/types/internal' 2 | 3 | export const getDoc = ([state]: InkInternal.Store) => { 4 | const { editor } = state() 5 | 6 | return editor.state.sliceDoc() 7 | } 8 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './destroy' 2 | export * from './focus' 3 | export * from './format' 4 | export * from './get_doc' 5 | export * from './insert' 6 | export * from './load' 7 | export * from './options' 8 | export * from './reconfigure' 9 | export * from './select' 10 | export * from './selections' 11 | export * from './update' 12 | export * from './wrap' 13 | -------------------------------------------------------------------------------- /src/api/insert.ts: -------------------------------------------------------------------------------- 1 | import type * as Ink from '/types/ink' 2 | import type InkInternal from '/types/internal' 3 | import { selections } from './selections' 4 | 5 | export const insert = ([state, setState]: InkInternal.Store, text: string, selection?: Ink.Editor.Selection, updateSelection = false) => { 6 | const { editor } = state() 7 | 8 | let start = selection?.start 9 | let end = selection?.end || selection?.start 10 | 11 | if (typeof start === 'undefined') { 12 | const current = selections([state, setState]).pop() as Ink.Editor.Selection 13 | 14 | start = current.start 15 | end = current.end 16 | } 17 | 18 | const updates = { changes: { from: start, to: end, insert: text } } 19 | 20 | if (updateSelection) { 21 | const anchor = start === end ? start + text.length : start 22 | const head = start === end ? start + text.length : start + text.length 23 | 24 | Object.assign(updates, { selection: { anchor, head } }) 25 | } 26 | 27 | editor.dispatch( 28 | editor.state.update(updates), 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/api/load.ts: -------------------------------------------------------------------------------- 1 | import { createState } from '/src/editor' 2 | import { override } from '/src/utils/merge' 3 | import type InkInternal from '/types/internal' 4 | 5 | export const load = ([state, setState]: InkInternal.Store, doc: string) => { 6 | setState(override(state(), { options: { doc } })) 7 | 8 | state().editor.setState(createState([state, setState])) 9 | } 10 | -------------------------------------------------------------------------------- /src/api/options.ts: -------------------------------------------------------------------------------- 1 | import type InkInternal from '/types/internal' 2 | 3 | export const options = ([state]: InkInternal.Store) => { 4 | return state().options 5 | } 6 | -------------------------------------------------------------------------------- /src/api/reconfigure.ts: -------------------------------------------------------------------------------- 1 | import { buildVendorUpdates } from '/src/extensions' 2 | import { override } from '/src/utils/merge' 3 | import type * as Ink from '/types/ink' 4 | import type InkInternal from '/types/internal' 5 | 6 | export const reconfigure = async ([state, setState]: InkInternal.Store, options: Ink.Options) => { 7 | const { workQueue } = state() 8 | 9 | return workQueue.enqueue(async () => { 10 | setState(override(state(), { options })) 11 | const effects = await buildVendorUpdates([state, setState]) 12 | 13 | state().editor.dispatch({ effects }) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /src/api/select.ts: -------------------------------------------------------------------------------- 1 | import type * as Ink from '/types/ink' 2 | import type InkInternal from '/types/internal' 3 | import * as InkValues from '/types/values' 4 | import { toCodeMirror } from '../editor/adapters/selections' 5 | 6 | export const select = (store: InkInternal.Store, options: Ink.Instance.SelectOptions = {}) => { 7 | if (options.selections) 8 | return selectMultiple(store, options.selections) 9 | if (options.selection) 10 | return selectOne(store, options.selection) 11 | if (options.at) 12 | return selectAt(store, options.at) 13 | } 14 | 15 | export const selectAt = (store: InkInternal.Store, at: Ink.Values.Selection) => { 16 | const [state] = store 17 | 18 | if (at === InkValues.Selection.Start) 19 | return selectOne(store, { start: 0, end: 0 }) 20 | 21 | if (at === InkValues.Selection.End) { 22 | const position = state().editor.state.doc.length 23 | 24 | return selectOne(store, { start: position, end: position }) 25 | } 26 | } 27 | 28 | export const selectMultiple = ([state]: InkInternal.Store, selections: Ink.Editor.Selection[]) => { 29 | const { editor } = state() 30 | 31 | editor.dispatch( 32 | editor.state.update({ 33 | selection: toCodeMirror(selections), 34 | }), 35 | ) 36 | } 37 | 38 | export const selectOne = (store: InkInternal.Store, selection: Ink.Editor.Selection) => { 39 | return selectMultiple(store, [selection]) 40 | } 41 | -------------------------------------------------------------------------------- /src/api/selections.ts: -------------------------------------------------------------------------------- 1 | import type * as Ink from '/types/ink' 2 | import type InkInternal from '/types/internal' 3 | import { toInk } from '../editor/adapters/selections' 4 | 5 | export const selections = ([state]: InkInternal.Store): Ink.Editor.Selection[] => { 6 | const { editor } = state() 7 | 8 | return toInk(editor.state.selection) 9 | } 10 | -------------------------------------------------------------------------------- /src/api/update.ts: -------------------------------------------------------------------------------- 1 | import type InkInternal from '/types/internal' 2 | 3 | export const update = ([state]: InkInternal.Store, doc: string) => { 4 | const { editor } = state() 5 | 6 | editor.dispatch( 7 | editor.state.update({ 8 | changes: { 9 | from: 0, 10 | to: editor.state.doc.length, 11 | insert: doc, 12 | }, 13 | }), 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/api/wrap.ts: -------------------------------------------------------------------------------- 1 | import type * as Ink from '/types/ink' 2 | import type InkInternal from '/types/internal' 3 | import { insert } from './insert' 4 | import { select } from './select' 5 | import { selections } from './selections' 6 | 7 | export const wrap = ([state, setState]: InkInternal.Store, { after, before, selection: userSelection }: Ink.Instance.WrapOptions) => { 8 | const { editor } = state() 9 | 10 | const selection = userSelection || selections([state, setState]).pop() || { start: 0, end: 0 } 11 | const text = editor.state.sliceDoc(selection.start, selection.end) 12 | 13 | insert([state, setState], `${before}${text}${after}`, selection) 14 | select([state, setState], { selections: [{ start: selection.start + before.length, end: selection.end + before.length }] }) 15 | } 16 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const HYDRATION_MARKER = 'data-ink-mde-ssr-hydration-marker' 2 | export const HYDRATION_MARKER_SELECTOR = `[${HYDRATION_MARKER}]` 3 | 4 | export const getHydrationMarkerProps = () => { 5 | if (import.meta.env.VITE_SSR) { 6 | return { 7 | [HYDRATION_MARKER]: true, 8 | } 9 | } 10 | 11 | return {} 12 | } 13 | -------------------------------------------------------------------------------- /src/editor/adapters/selections.ts: -------------------------------------------------------------------------------- 1 | import { EditorSelection, SelectionRange } from '@codemirror/state' 2 | import type * as Ink from '/types/ink' 3 | 4 | export const toCodeMirror = (selections: Ink.Editor.Selection[]) => { 5 | const ranges = selections.map((selection): SelectionRange => { 6 | const range = SelectionRange.fromJSON({ anchor: selection.start, head: selection.end }) 7 | 8 | return range 9 | }) 10 | 11 | return EditorSelection.create(ranges) 12 | } 13 | 14 | export const toInk = (selection: EditorSelection) => { 15 | const selections = selection.ranges.map((range: SelectionRange): Ink.Editor.Selection => { 16 | return { 17 | end: range.anchor < range.head ? range.head : range.anchor, 18 | start: range.head < range.anchor ? range.head : range.anchor, 19 | } 20 | }) 21 | 22 | return selections 23 | } 24 | -------------------------------------------------------------------------------- /src/editor/extensions/appearance.ts: -------------------------------------------------------------------------------- 1 | import type { Extension } from '@codemirror/state' 2 | import { EditorView } from '@codemirror/view' 3 | 4 | export const appearance = (isDark: boolean): Extension => { 5 | return [ 6 | EditorView.theme({ 7 | '.cm-scroller': { 8 | fontFamily: 'var(--ink-internal-font-family)', 9 | }, 10 | }, { dark: isDark }), 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/editor/extensions/autocomplete.ts: -------------------------------------------------------------------------------- 1 | import { autocompletion, closeBrackets } from '@codemirror/autocomplete' 2 | import { filterPlugins, partitionPlugins } from '/src/utils/options' 3 | import type * as Ink from '/types/ink' 4 | import { pluginTypes } from '/types/values' 5 | 6 | export const autocomplete = (options: Ink.OptionsResolved) => { 7 | // Todo: Handle lazy-loaded completions. 8 | const [_lazyCompletions, completions] = partitionPlugins(filterPlugins(pluginTypes.completion, options)) 9 | 10 | return [ 11 | autocompletion({ 12 | defaultKeymap: true, 13 | icons: false, 14 | override: completions, 15 | optionClass: () => 'ink-tooltip-option', 16 | }), 17 | closeBrackets(), 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/editor/extensions/blockquote.ts: -------------------------------------------------------------------------------- 1 | import { syntaxTree } from '@codemirror/language' 2 | import type { Extension } from '@codemirror/state' 3 | import { RangeSetBuilder } from '@codemirror/state' 4 | import type { EditorView } from '@codemirror/view' 5 | import { Decoration, ViewPlugin } from '@codemirror/view' 6 | 7 | // const mark = 'QuoteMark' 8 | 9 | const blockquoteSyntaxNodes = [ 10 | 'Blockquote', 11 | ] 12 | 13 | const blockquoteDecoration = Decoration.line({ attributes: { class: 'cm-blockquote' } }) 14 | const blockquoteOpenDecoration = Decoration.line({ attributes: { class: 'cm-blockquote-open' } }) 15 | const blockquoteCloseDecoration = Decoration.line({ attributes: { class: 'cm-blockquote-close' } }) 16 | 17 | const blockquotePlugin = ViewPlugin.define((view: EditorView) => { 18 | return { 19 | update: () => { 20 | return decorate(view) 21 | }, 22 | } 23 | }, { decorations: plugin => plugin.update() }) 24 | 25 | const decorate = (view: EditorView) => { 26 | const builder = new RangeSetBuilder() 27 | const tree = syntaxTree(view.state) 28 | 29 | for (const visibleRange of view.visibleRanges) { 30 | for (let position = visibleRange.from; position < visibleRange.to;) { 31 | const line = view.state.doc.lineAt(position) 32 | 33 | tree.iterate({ 34 | enter({ type, from, to }) { 35 | if (type.name !== 'Document') { 36 | if (blockquoteSyntaxNodes.includes(type.name)) { 37 | builder.add(line.from, line.from, blockquoteDecoration) 38 | 39 | const openLine = view.state.doc.lineAt(from) 40 | const closeLine = view.state.doc.lineAt(to) 41 | 42 | if (openLine.number === line.number) 43 | builder.add(line.from, line.from, blockquoteOpenDecoration) 44 | 45 | if (closeLine.number === line.number) 46 | builder.add(line.from, line.from, blockquoteCloseDecoration) 47 | 48 | return false 49 | } 50 | } 51 | }, 52 | from: line.from, 53 | to: line.to, 54 | }) 55 | 56 | position = line.to + 1 57 | } 58 | } 59 | 60 | return builder.finish() 61 | } 62 | 63 | export const blockquote = (): Extension => { 64 | return [ 65 | blockquotePlugin, 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /src/editor/extensions/code.ts: -------------------------------------------------------------------------------- 1 | import { syntaxTree } from '@codemirror/language' 2 | import type { Extension } from '@codemirror/state' 3 | import { RangeSetBuilder } from '@codemirror/state' 4 | import type { EditorView } from '@codemirror/view' 5 | import { Decoration, ViewPlugin } from '@codemirror/view' 6 | 7 | const codeBlockSyntaxNodes = [ 8 | 'CodeBlock', 9 | 'FencedCode', 10 | 'HTMLBlock', 11 | 'CommentBlock', 12 | ] 13 | 14 | const sharedAttributes = { 15 | // Prevent spellcheck in all code blocks. The Grammarly extension might not respect these values. 16 | 'data-enable-grammarly': 'false', 17 | 'data-gramm': 'false', 18 | 'data-grammarly-skip': 'true', 19 | 'spellcheck': 'false', 20 | } 21 | 22 | const codeBlockDecoration = Decoration.line({ attributes: { ...sharedAttributes, class: 'cm-codeblock' } }) 23 | const codeBlockOpenDecoration = Decoration.line({ attributes: { ...sharedAttributes, class: 'cm-codeblock-open' } }) 24 | const codeBlockCloseDecoration = Decoration.line({ attributes: { ...sharedAttributes, class: 'cm-codeblock-close' } }) 25 | const codeDecoration = Decoration.mark({ attributes: { ...sharedAttributes, class: 'cm-code' } }) 26 | const codeOpenDecoration = Decoration.mark({ attributes: { ...sharedAttributes, class: 'cm-code cm-code-open' } }) 27 | const codeCloseDecoration = Decoration.mark({ attributes: { ...sharedAttributes, class: 'cm-code cm-code-close' } }) 28 | 29 | const codeBlockPlugin = ViewPlugin.define((view: EditorView) => { 30 | return { 31 | update: () => { 32 | return decorate(view) 33 | }, 34 | } 35 | }, { decorations: plugin => plugin.update() }) 36 | 37 | const decorate = (view: EditorView) => { 38 | const builder = new RangeSetBuilder() 39 | const tree = syntaxTree(view.state) 40 | 41 | for (const visibleRange of view.visibleRanges) { 42 | for (let position = visibleRange.from; position < visibleRange.to;) { 43 | const line = view.state.doc.lineAt(position) 44 | let inlineCode: { from: number, to: number, innerFrom: number, innerTo: number } 45 | 46 | tree.iterate({ 47 | enter({ type, from, to }) { 48 | if (type.name !== 'Document') { 49 | if (codeBlockSyntaxNodes.includes(type.name)) { 50 | builder.add(line.from, line.from, codeBlockDecoration) 51 | 52 | const openLine = view.state.doc.lineAt(from) 53 | const closeLine = view.state.doc.lineAt(to) 54 | 55 | if (openLine.number === line.number) 56 | builder.add(line.from, line.from, codeBlockOpenDecoration) 57 | 58 | if (closeLine.number === line.number) 59 | builder.add(line.from, line.from, codeBlockCloseDecoration) 60 | 61 | return false 62 | } else if (type.name === 'InlineCode') { 63 | // Store a reference for the last inline code node. 64 | inlineCode = { from, to, innerFrom: from, innerTo: to } 65 | } else if (type.name === 'CodeMark') { 66 | // Make sure the code mark is a part of the previously stored inline code node. 67 | if (from === inlineCode.from) { 68 | inlineCode.innerFrom = to 69 | 70 | builder.add(from, to, codeOpenDecoration) 71 | } else if (to === inlineCode.to) { 72 | inlineCode.innerTo = from 73 | 74 | builder.add(inlineCode.innerFrom, inlineCode.innerTo, codeDecoration) 75 | builder.add(from, to, codeCloseDecoration) 76 | } 77 | } 78 | } 79 | }, 80 | from: line.from, 81 | to: line.to, 82 | }) 83 | 84 | position = line.to + 1 85 | } 86 | } 87 | 88 | return builder.finish() 89 | } 90 | 91 | export const code = (): Extension => { 92 | return [ 93 | codeBlockPlugin, 94 | ] 95 | } 96 | -------------------------------------------------------------------------------- /src/editor/extensions/extension.ts: -------------------------------------------------------------------------------- 1 | import type { EditorState, Extension } from '@codemirror/state' 2 | import { RangeSet, StateField } from '@codemirror/state' 3 | import type { DecorationSet, WidgetType } from '@codemirror/view' 4 | import { Decoration, EditorView } from '@codemirror/view' 5 | import type { StyleSpec } from 'style-mod' 6 | 7 | export interface CustomExtensionOptions { 8 | theme: { [selector: string]: StyleSpec }, 9 | decorator: (state: EditorState) => CustomExtensionWidget[], 10 | } 11 | 12 | export interface CustomExtensionWidget { 13 | widget: WidgetType, 14 | from: number, 15 | to?: number, 16 | } 17 | 18 | export const extension = ({ theme, decorator }: CustomExtensionOptions): Extension => { 19 | const customTheme = EditorView.baseTheme(theme) 20 | 21 | const decoration = (widget: WidgetType) => Decoration.widget({ 22 | widget, 23 | side: -1, 24 | block: true, 25 | }) 26 | 27 | const evaluate = (state: EditorState) => { 28 | const customWidgets = decorator(state) 29 | const decorations = customWidgets.map((customWidget) => { 30 | return decoration(customWidget.widget).range(customWidget.from, customWidget.to) 31 | }) 32 | 33 | return decorations.length > 0 ? RangeSet.of(decorations) : Decoration.none 34 | } 35 | 36 | const customField = StateField.define({ 37 | create(state) { 38 | return evaluate(state) 39 | }, 40 | update(customs, transaction) { 41 | if (transaction.docChanged) 42 | return evaluate(transaction.state) 43 | 44 | return customs.map(transaction.changes) 45 | }, 46 | provide(field) { 47 | return EditorView.decorations.from(field) 48 | }, 49 | }) 50 | 51 | return [ 52 | customTheme, 53 | customField, 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /src/editor/extensions/images.ts: -------------------------------------------------------------------------------- 1 | import { syntaxTree } from '@codemirror/language' 2 | import type { EditorState, Extension, Range } from '@codemirror/state' 3 | import { RangeSet, StateField } from '@codemirror/state' 4 | import type { DecorationSet } from '@codemirror/view' 5 | import { Decoration, EditorView, WidgetType } from '@codemirror/view' 6 | 7 | interface ImageWidgetParams { 8 | url: string, 9 | } 10 | 11 | class ImageWidget extends WidgetType { 12 | readonly url 13 | 14 | constructor({ url }: ImageWidgetParams) { 15 | super() 16 | 17 | this.url = url 18 | } 19 | 20 | eq(imageWidget: ImageWidget) { 21 | return imageWidget.url === this.url 22 | } 23 | 24 | toDOM() { 25 | const container = document.createElement('div') 26 | const backdrop = container.appendChild(document.createElement('div')) 27 | const figure = backdrop.appendChild(document.createElement('figure')) 28 | const image = figure.appendChild(document.createElement('img')) 29 | 30 | container.setAttribute('aria-hidden', 'true') 31 | container.className = 'cm-image-container' 32 | backdrop.className = 'cm-image-backdrop' 33 | figure.className = 'cm-image-figure' 34 | image.className = 'cm-image-img' 35 | image.src = this.url 36 | 37 | container.style.paddingBottom = '0.5rem' 38 | container.style.paddingTop = '0.5rem' 39 | 40 | backdrop.classList.add('cm-image-backdrop') 41 | 42 | backdrop.style.borderRadius = 'var(--ink-internal-border-radius)' 43 | backdrop.style.display = 'flex' 44 | backdrop.style.alignItems = 'center' 45 | backdrop.style.justifyContent = 'center' 46 | backdrop.style.overflow = 'hidden' 47 | backdrop.style.maxWidth = '100%' 48 | 49 | figure.style.margin = '0' 50 | 51 | image.style.display = 'block' 52 | image.style.maxHeight = 'var(--ink-internal-block-max-height)' 53 | image.style.maxWidth = '100%' 54 | image.style.width = '100%' 55 | 56 | return container 57 | } 58 | } 59 | 60 | export const images = (): Extension => { 61 | const imageRegex = /!\[.*?\]\((?.*?)\)/ 62 | 63 | const imageDecoration = (imageWidgetParams: ImageWidgetParams) => Decoration.widget({ 64 | widget: new ImageWidget(imageWidgetParams), 65 | side: -1, 66 | block: true, 67 | }) 68 | 69 | const decorate = (state: EditorState) => { 70 | const widgets: Range[] = [] 71 | 72 | syntaxTree(state).iterate({ 73 | enter: ({ type, from, to }) => { 74 | if (type.name === 'Image') { 75 | const result = imageRegex.exec(state.doc.sliceString(from, to)) 76 | 77 | if (result && result.groups && result.groups.url) 78 | widgets.push(imageDecoration({ url: result.groups.url }).range(state.doc.lineAt(from).from)) 79 | } 80 | }, 81 | }) 82 | 83 | return widgets.length > 0 ? RangeSet.of(widgets) : Decoration.none 84 | } 85 | 86 | const imagesField = StateField.define({ 87 | create(state) { 88 | return decorate(state) 89 | }, 90 | update(images, transaction) { 91 | if (transaction.docChanged) 92 | return decorate(transaction.state) 93 | 94 | return images.map(transaction.changes) 95 | }, 96 | provide(field) { 97 | return EditorView.decorations.from(field) 98 | }, 99 | }) 100 | 101 | return [ 102 | imagesField, 103 | ] 104 | } 105 | -------------------------------------------------------------------------------- /src/editor/extensions/indentWithTab.ts: -------------------------------------------------------------------------------- 1 | import { indentLess, indentMore } from '@codemirror/commands' 2 | import { type Extension } from '@codemirror/state' 3 | import { keymap } from '@codemirror/view' 4 | 5 | export const indentWithTab = ({ tab = true, shiftTab = true } = {}): Extension => { 6 | return keymap.of([ 7 | { 8 | key: 'Tab', 9 | run: tab ? indentMore : undefined, 10 | }, 11 | { 12 | key: 'Shift-Tab', 13 | run: shiftTab ? indentLess : undefined, 14 | }, 15 | ]) 16 | } 17 | -------------------------------------------------------------------------------- /src/editor/extensions/ink.ts: -------------------------------------------------------------------------------- 1 | import type { Extension } from '@codemirror/state' 2 | import { EditorView } from '@codemirror/view' 3 | 4 | const inkClassExtensions = () => { 5 | return [ 6 | EditorView.editorAttributes.of({ 7 | class: 'ink-mde-container', 8 | }), 9 | EditorView.contentAttributes.of({ 10 | class: 'ink-mde-editor-content', 11 | }), 12 | // Todo: Maybe open a PR to add scrollerAttributes? 13 | ] 14 | } 15 | 16 | export const ink = (): Extension => { 17 | return [ 18 | ...inkClassExtensions(), 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/editor/extensions/line_wrapping.ts: -------------------------------------------------------------------------------- 1 | import type { Extension } from '@codemirror/state' 2 | import { EditorView } from '@codemirror/view' 3 | 4 | export const lineWrapping = (): Extension => { 5 | return EditorView.lineWrapping 6 | } 7 | -------------------------------------------------------------------------------- /src/editor/extensions/placeholder.ts: -------------------------------------------------------------------------------- 1 | import { placeholder } from '@codemirror/view' 2 | 3 | export { placeholder } 4 | -------------------------------------------------------------------------------- /src/editor/extensions/readonly.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from '@codemirror/state' 2 | 3 | export const readonly = () => { 4 | return EditorState.readOnly.of(true) 5 | } 6 | -------------------------------------------------------------------------------- /src/editor/extensions/search.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | SearchQuery, 3 | findNext, 4 | findPrevious, 5 | getSearchQuery, 6 | search as searchExtension, 7 | searchKeymap, 8 | setSearchQuery, 9 | } from '@codemirror/search' 10 | import { keymap, runScopeHandlers } from '@codemirror/view' 11 | 12 | export const search = () => { 13 | return [ 14 | searchExtension({ 15 | top: true, 16 | createPanel: (view) => { 17 | let query = getSearchQuery(view.state) 18 | 19 | const wrapper = document.createElement('div') 20 | const input = document.createElement('input') 21 | 22 | wrapper.setAttribute('class', 'ink-mde-search-panel') 23 | input.setAttribute('attr:main-field', 'true') 24 | input.setAttribute('class', 'ink-mde-search-input') 25 | input.setAttribute('type', 'text') 26 | input.setAttribute('value', query.search) 27 | 28 | wrapper.appendChild(input) 29 | 30 | const handleKeyDown = (event: KeyboardEvent) => { 31 | if (runScopeHandlers(view, event, 'search-panel')) return event.preventDefault() 32 | 33 | if (event.code === 'Enter') { 34 | event.preventDefault() 35 | 36 | if (event.shiftKey) { 37 | findPrevious(view) 38 | } else { 39 | findNext(view) 40 | } 41 | } 42 | } 43 | 44 | const updateSearch = (event: Event) => { 45 | // @ts-expect-error "value" is not a recognized property of EventTarget. 46 | const { value } = event.target 47 | 48 | query = new SearchQuery({ search: value }) 49 | 50 | view.dispatch({ effects: setSearchQuery.of(query) }) 51 | } 52 | 53 | input.addEventListener('input', updateSearch) 54 | input.addEventListener('keydown', handleKeyDown) 55 | 56 | return { 57 | dom: wrapper, 58 | mount: () => { 59 | input.focus() 60 | }, 61 | top: true, 62 | } 63 | }, 64 | }), 65 | keymap.of(searchKeymap), 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /src/editor/extensions/spellcheck.ts: -------------------------------------------------------------------------------- 1 | import type { Extension } from '@codemirror/state' 2 | import { EditorView } from '@codemirror/view' 3 | 4 | export const spellcheck = (): Extension => { 5 | return EditorView.contentAttributes.of({ 6 | spellcheck: 'true', 7 | }) 8 | } 9 | -------------------------------------------------------------------------------- /src/editor/extensions/theme.ts: -------------------------------------------------------------------------------- 1 | import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' 2 | import type { Extension } from '@codemirror/state' 3 | import { tags } from '@lezer/highlight' 4 | 5 | export const theme = (): Extension => { 6 | const extension = syntaxHighlighting( 7 | HighlightStyle.define([ 8 | // ordered by lowest to highest precedence 9 | { 10 | tag: tags.atom, 11 | color: 'var(--ink-internal-syntax-atom-color)', 12 | }, 13 | { 14 | tag: tags.meta, 15 | color: 'var(--ink-internal-syntax-meta-color)', 16 | }, 17 | // emphasis types 18 | { 19 | tag: tags.emphasis, 20 | color: 'var(--ink-internal-syntax-emphasis-color)', 21 | fontStyle: 'var(--ink-internal-syntax-emphasis-font-style)', 22 | }, 23 | { 24 | tag: tags.strong, 25 | color: 'var(--ink-internal-syntax-strong-color)', 26 | fontWeight: 'var(--ink-internal-syntax-strong-font-weight)', 27 | }, 28 | { 29 | tag: tags.strikethrough, 30 | color: 'var(--ink-internal-syntax-strikethrough-color)', 31 | textDecoration: 'var(--ink-internal-syntax-strikethrough-text-decoration)', 32 | }, 33 | // comment group 34 | { 35 | tag: tags.comment, 36 | color: 'var(--ink-internal-syntax-comment-color)', 37 | fontStyle: 'var(--ink-internal-syntax-comment-font-style)', 38 | }, 39 | // monospace 40 | { 41 | tag: tags.monospace, 42 | color: 'var(--ink-internal-syntax-code-color)', 43 | fontFamily: 'var(--ink-internal-syntax-code-font-family)', 44 | }, 45 | // name group 46 | { 47 | tag: tags.name, 48 | color: 'var(--ink-internal-syntax-name-color)', 49 | }, 50 | { 51 | tag: tags.labelName, 52 | color: 'var(--ink-internal-syntax-name-label-color)', 53 | }, 54 | { 55 | tag: tags.propertyName, 56 | color: 'var(--ink-internal-syntax-name-property-color)', 57 | }, 58 | { 59 | tag: tags.definition(tags.propertyName), 60 | color: 'var(--ink-internal-syntax-name-property-definition-color)', 61 | }, 62 | { 63 | tag: tags.variableName, 64 | color: 'var(--ink-internal-syntax-name-variable-color)', 65 | }, 66 | { 67 | tag: tags.definition(tags.variableName), 68 | color: 'var(--ink-internal-syntax-name-variable-definition-color)', 69 | }, 70 | { 71 | tag: tags.local(tags.variableName), 72 | color: 'var(--ink-internal-syntax-name-variable-local-color)', 73 | }, 74 | { 75 | tag: tags.special(tags.variableName), 76 | color: 'var(--ink-internal-syntax-name-variable-special-color)', 77 | }, 78 | // headings 79 | { 80 | tag: tags.heading, 81 | color: 'var(--ink-internal-syntax-heading-color)', 82 | fontWeight: 'var(--ink-internal-syntax-heading-font-weight)', 83 | }, 84 | { 85 | tag: tags.heading1, 86 | color: 'var(--ink-internal-syntax-heading1-color)', 87 | fontSize: 'var(--ink-internal-syntax-heading1-font-size)', 88 | fontWeight: 'var(--ink-internal-syntax-heading1-font-weight)', 89 | }, 90 | { 91 | tag: tags.heading2, 92 | color: 'var(--ink-internal-syntax-heading2-color)', 93 | fontSize: 'var(--ink-internal-syntax-heading2-font-size)', 94 | fontWeight: 'var(--ink-internal-syntax-heading2-font-weight)', 95 | }, 96 | { 97 | tag: tags.heading3, 98 | color: 'var(--ink-internal-syntax-heading3-color)', 99 | fontSize: 'var(--ink-internal-syntax-heading3-font-size)', 100 | fontWeight: 'var(--ink-internal-syntax-heading3-font-weight)', 101 | }, 102 | { 103 | tag: tags.heading4, 104 | color: 'var(--ink-internal-syntax-heading4-color)', 105 | fontSize: 'var(--ink-internal-syntax-heading4-font-size)', 106 | fontWeight: 'var(--ink-internal-syntax-heading4-font-weight)', 107 | }, 108 | { 109 | tag: tags.heading5, 110 | color: 'var(--ink-internal-syntax-heading5-color)', 111 | fontSize: 'var(--ink-internal-syntax-heading5-font-size)', 112 | fontWeight: 'var(--ink-internal-syntax-heading5-font-weight)', 113 | }, 114 | { 115 | tag: tags.heading6, 116 | color: 'var(--ink-internal-syntax-heading6-color)', 117 | fontSize: 'var(--ink-internal-syntax-heading6-font-size)', 118 | fontWeight: 'var(--ink-internal-syntax-heading6-font-weight)', 119 | }, 120 | // contextual tag types 121 | { 122 | tag: tags.keyword, 123 | color: 'var(--ink-internal-syntax-keyword-color)', 124 | }, 125 | { 126 | tag: tags.number, 127 | color: 'var(--ink-internal-syntax-number-color)', 128 | }, 129 | { 130 | tag: tags.operator, 131 | color: 'var(--ink-internal-syntax-operator-color)', 132 | }, 133 | { 134 | tag: tags.punctuation, 135 | color: 'var(--ink-internal-syntax-punctuation-color)', 136 | }, 137 | { 138 | tag: tags.link, 139 | color: 'var(--ink-internal-syntax-link-color)', 140 | wordBreak: 'break-all', 141 | }, 142 | { 143 | tag: tags.url, 144 | color: 'var(--ink-internal-syntax-url-color)', 145 | wordBreak: 'break-all', 146 | }, 147 | // string group 148 | { 149 | tag: tags.string, 150 | color: 'var(--ink-internal-syntax-string-color)', 151 | }, 152 | { 153 | tag: tags.special(tags.string), 154 | color: 'var(--ink-internal-syntax-string-special-color)', 155 | }, 156 | // processing instructions 157 | { 158 | tag: tags.processingInstruction, 159 | color: 'var(--ink-internal-syntax-processing-instruction-color)', 160 | }, 161 | ]), 162 | ) 163 | 164 | return [ 165 | extension, 166 | ] 167 | } 168 | -------------------------------------------------------------------------------- /src/editor/extensions/vim.ts: -------------------------------------------------------------------------------- 1 | export { vim } from '@replit/codemirror-vim' 2 | -------------------------------------------------------------------------------- /src/editor/index.ts: -------------------------------------------------------------------------------- 1 | export * from './state' 2 | export * from './view' 3 | -------------------------------------------------------------------------------- /src/editor/state.ts: -------------------------------------------------------------------------------- 1 | import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' 2 | import type { EditorSelection } from '@codemirror/state' 3 | import { EditorState } from '@codemirror/state' 4 | import { keymap } from '@codemirror/view' 5 | import { buildVendors } from '/src/extensions' 6 | import type * as Ink from '/types/ink' 7 | import type InkInternal from '/types/internal' 8 | import { toCodeMirror } from './adapters/selections' 9 | import { blockquote } from './extensions/blockquote' 10 | import { code } from './extensions/code' 11 | import { ink } from './extensions/ink' 12 | import { lineWrapping } from './extensions/line_wrapping' 13 | import { theme } from './extensions/theme' 14 | 15 | const toVendorSelection = (selections: Ink.Editor.Selection[]): EditorSelection | undefined => { 16 | if (selections.length > 0) { 17 | return toCodeMirror(selections) 18 | } 19 | } 20 | 21 | export const createState = ([state, setState]: InkInternal.Store): InkInternal.Vendor.State => { 22 | const { selections } = state().options 23 | 24 | return EditorState.create({ 25 | doc: state().options.doc, 26 | selection: toVendorSelection(selections), 27 | extensions: [ 28 | keymap.of([ 29 | ...defaultKeymap, 30 | ...historyKeymap, 31 | ]), 32 | blockquote(), 33 | code(), 34 | history(), 35 | ink(), 36 | lineWrapping(), 37 | theme(), 38 | ...buildVendors([state, setState]), 39 | ], 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /src/editor/view.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from '@codemirror/view' 2 | import type InkInternal from '/types/internal' 3 | import { createState } from './state' 4 | 5 | export const createView = ([state, setState]: InkInternal.Store, target?: HTMLElement): InkInternal.Editor => { 6 | const rootNode = target?.getRootNode() 7 | const root = rootNode?.nodeType === 11 ? rootNode as ShadowRoot : undefined 8 | 9 | const editor = new EditorView({ 10 | dispatch: (transaction: InkInternal.Vendor.Transaction) => { 11 | const { options } = state() 12 | const newDoc = transaction.newDoc.toString() 13 | 14 | options.hooks.beforeUpdate(newDoc) 15 | editor.update([transaction]) 16 | 17 | if (transaction.docChanged) { 18 | setState({ ...state(), doc: newDoc }) 19 | 20 | options.hooks.afterUpdate(newDoc) 21 | } 22 | }, 23 | root, 24 | state: createState([state, setState]), 25 | }) 26 | 27 | return editor 28 | } 29 | -------------------------------------------------------------------------------- /src/extensions.ts: -------------------------------------------------------------------------------- 1 | import { Compartment } from '@codemirror/state' 2 | import { markdown } from '/src/markdown' 3 | import { isAutoDark } from '/src/ui/utils' 4 | import { filterPlugins, partitionPlugins } from '/src/utils/options' 5 | import { type InkInternal } from '/types' 6 | import { appearanceTypes, pluginTypes } from '/types/values' 7 | import { appearance } from './editor/extensions/appearance' 8 | 9 | export const buildVendors = ([state, setState]: InkInternal.Store) => { 10 | const extensions = state().extensions.map(e => e.initialValue([state, setState])) 11 | 12 | return extensions 13 | } 14 | 15 | export const buildVendorUpdates = async ([state, setState]: InkInternal.Store) => { 16 | const effects = await Promise.all( 17 | state().extensions.map(async (extension) => { 18 | return await extension.reconfigure([state, setState]) 19 | }), 20 | ) 21 | 22 | return effects 23 | } 24 | 25 | export const extension = (resolver: InkInternal.ExtensionResolver): InkInternal.Extension => { 26 | const compartment = new Compartment() 27 | 28 | return { 29 | compartment, 30 | initialValue: (store: InkInternal.Store) => { 31 | return compartment.of(resolver(store)) 32 | }, 33 | reconfigure: (store: InkInternal.Store) => { 34 | return compartment.reconfigure(resolver(store)) 35 | }, 36 | } 37 | } 38 | 39 | export const lazyExtension = (reconfigure: InkInternal.LazyExtensionResolver): InkInternal.LazyExtension => { 40 | const compartment = new Compartment() 41 | 42 | return { 43 | compartment, 44 | initialValue: () => { 45 | return compartment.of([]) 46 | }, 47 | reconfigure: (store: InkInternal.Store) => { 48 | return reconfigure(store, compartment) 49 | }, 50 | } 51 | } 52 | 53 | export const createExtensions = () => { 54 | return [ 55 | markdown(), 56 | ...resolvers.map(r => extension(r)), 57 | ...lazyResolvers.map(r => lazyExtension(r)), 58 | ] 59 | } 60 | 61 | export const resolvers: InkInternal.ExtensionResolvers = [ 62 | ([state]: InkInternal.Store) => { 63 | const [_lazyExtensions, extensions] = partitionPlugins(filterPlugins(pluginTypes.default, state().options)) 64 | 65 | return extensions 66 | }, 67 | ([state]: InkInternal.Store) => { 68 | const isDark = state().options.interface.appearance === appearanceTypes.dark 69 | const isAuto = state().options.interface.appearance === appearanceTypes.auto 70 | const extension = appearance(isDark || (isAuto && isAutoDark())) 71 | 72 | return extension 73 | }, 74 | ] 75 | 76 | export const lazyResolvers: InkInternal.LazyExtensionResolvers = [ 77 | async ([state]: InkInternal.Store, compartment: InkInternal.Vendor.Compartment) => { 78 | const [lazyExtensions] = partitionPlugins(filterPlugins(pluginTypes.default, state().options)) 79 | 80 | if (lazyExtensions.length > 0) { 81 | return compartment.reconfigure(await Promise.all(lazyExtensions)) 82 | } 83 | 84 | return compartment.reconfigure([]) 85 | }, 86 | async ([state]: InkInternal.Store, compartment: InkInternal.Vendor.Compartment) => { 87 | if (state().options.interface.autocomplete) { 88 | const { autocomplete } = await import('./editor/extensions/autocomplete') 89 | 90 | return compartment.reconfigure(autocomplete(state().options)) 91 | } 92 | 93 | return compartment.reconfigure([]) 94 | }, 95 | async ([state]: InkInternal.Store, compartment: InkInternal.Vendor.Compartment) => { 96 | if (state().options.interface.images) { 97 | const { images } = await import('./editor/extensions/images') 98 | 99 | return compartment.reconfigure(images()) 100 | } 101 | 102 | return compartment.reconfigure([]) 103 | }, 104 | async ([state]: InkInternal.Store, compartment: InkInternal.Vendor.Compartment) => { 105 | const { keybindings, trapTab } = state().options 106 | const tab = trapTab ?? keybindings.tab 107 | const shiftTab = trapTab ?? keybindings.shiftTab 108 | 109 | if (tab || shiftTab) { 110 | const { indentWithTab } = await import('./editor/extensions/indentWithTab') 111 | 112 | return compartment.reconfigure(indentWithTab({ tab, shiftTab })) 113 | } 114 | 115 | return compartment.reconfigure([]) 116 | }, 117 | async ([state]: InkInternal.Store, compartment: InkInternal.Vendor.Compartment) => { 118 | const { options } = state() 119 | 120 | if (options.lists || options.interface.lists) { 121 | const { lists } = await import('./editor/extensions/lists') 122 | 123 | let bullet = true 124 | let number = true 125 | let task = true 126 | 127 | if (typeof options.lists === 'object') { 128 | bullet = typeof options.lists.bullet === 'undefined' ? false : options.lists.bullet 129 | number = typeof options.lists.number === 'undefined' ? false : options.lists.number 130 | task = typeof options.lists.task === 'undefined' ? false : options.lists.task 131 | } 132 | 133 | return compartment.reconfigure(lists({ bullet, number, task })) 134 | } 135 | 136 | return compartment.reconfigure([]) 137 | }, 138 | async ([state]: InkInternal.Store, compartment: InkInternal.Vendor.Compartment) => { 139 | if (state().options.placeholder) { 140 | const { placeholder } = await import('./editor/extensions/placeholder') 141 | 142 | return compartment.reconfigure(placeholder(state().options.placeholder)) 143 | } 144 | 145 | return compartment.reconfigure([]) 146 | }, 147 | async ([state]: InkInternal.Store, compartment: InkInternal.Vendor.Compartment) => { 148 | if (state().options.interface.readonly) { 149 | const { readonly } = await import('./editor/extensions/readonly') 150 | 151 | return compartment.reconfigure(readonly()) 152 | } 153 | 154 | return compartment.reconfigure([]) 155 | }, 156 | async ([state]: InkInternal.Store, compartment: InkInternal.Vendor.Compartment) => { 157 | if (state().options.search) { 158 | const { search } = await import('./editor/extensions/search') 159 | 160 | return compartment.reconfigure(search()) 161 | } 162 | 163 | return compartment.reconfigure([]) 164 | }, 165 | async ([state]: InkInternal.Store, compartment: InkInternal.Vendor.Compartment) => { 166 | if (state().options.interface.spellcheck) { 167 | const { spellcheck } = await import('./editor/extensions/spellcheck') 168 | 169 | return compartment.reconfigure(spellcheck()) 170 | } 171 | 172 | return compartment.reconfigure([]) 173 | }, 174 | async ([state]: InkInternal.Store, compartment: InkInternal.Vendor.Compartment) => { 175 | if (state().options.vim) { 176 | const { vim } = await import('./editor/extensions/vim') 177 | 178 | return compartment.reconfigure(vim()) 179 | } 180 | 181 | return compartment.reconfigure([]) 182 | }, 183 | ] 184 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { hydrate as solidHydrate, render as solidRender, renderToString as solidRenderToString } from 'solid-js/web' 2 | import { HYDRATION_MARKER_SELECTOR } from '/src/constants' 3 | import { makeInstance } from '/src/instance' 4 | import { makeStore } from '/src/store' 5 | import { App } from '/src/ui/app' 6 | import { isPromise } from '/src/utils/inspect' 7 | import { type PluginValueForType } from '/src/utils/options' 8 | import type * as Ink from '/types/ink' 9 | 10 | export type * from '/types/ink' 11 | export { appearanceTypes, pluginTypes } from '/types/values' 12 | 13 | export const defineConfig = (config: T) => config 14 | export const defineOptions = (options: T) => options 15 | export const definePlugin = (plugin: T) => plugin 16 | 17 | export const hydrate = (target: HTMLElement, options: Ink.Options = {}): Ink.AwaitableInstance => { 18 | const store = makeStore(options) 19 | 20 | if (!import.meta.env.VITE_SSR) { 21 | solidPrepareForHydration() 22 | solidHydrate(() => , target) 23 | } 24 | 25 | return makeInstance(store) 26 | } 27 | 28 | export const ink = (target: HTMLElement, options: Ink.Options = {}): Ink.AwaitableInstance => { 29 | const hasHydrationMarker = !!target.querySelector(HYDRATION_MARKER_SELECTOR) 30 | 31 | if (hasHydrationMarker) { 32 | return hydrate(target, options) 33 | } 34 | 35 | return render(target, options) 36 | } 37 | 38 | export const inkPlugin = ({ key = '', type, value }: { key?: string, type?: T, value: () => PluginValueForType }) => { 39 | return new Proxy({ key, type: type || 'default' } as Ink.Options.Plugin, { 40 | get: (target, prop: keyof Ink.Options.Plugin, _receiver) => { 41 | if (prop === 'value' && !target[prop]) { 42 | target.value = value() 43 | 44 | if (isPromise(target.value)) { 45 | return target.value.then(val => target.value = val) 46 | } 47 | 48 | return target.value 49 | } 50 | 51 | return target[prop] 52 | }, 53 | }) 54 | } 55 | 56 | export const plugin = inkPlugin 57 | 58 | export const render = (target: HTMLElement, options: Ink.Options = {}): Ink.AwaitableInstance => { 59 | const store = makeStore(options) 60 | 61 | if (!import.meta.env.VITE_SSR) { 62 | solidRender(() => , target) 63 | } 64 | 65 | return makeInstance(store) 66 | } 67 | 68 | export const renderToString = (options: Ink.Options = {}): string => { 69 | const store = makeStore(options) 70 | 71 | // Needed for tree-shaking purposes. 72 | if (!import.meta.env.VITE_SSR) { 73 | return '' 74 | } 75 | 76 | return solidRenderToString(() => ) 77 | } 78 | 79 | export const solidPrepareForHydration = () => { 80 | // @ts-expect-error Todo: This is a third-party vendor script. 81 | // eslint-disable-next-line 82 | let e,t;e=window._$HY||(window._$HY={events:[],completed:new WeakSet,r:{}}),t=e=>e&&e.hasAttribute&&(e.hasAttribute("data-hk")?e:t(e.host&&e.host instanceof Node?e.host:e.parentNode)),['click', 'input'].forEach((o=>document.addEventListener(o,(o=>{let s=o.composedPath&&o.composedPath()[0]||o.target,a=t(s);a&&!e.completed.has(a)&&e.events.push([a,o])})))),e.init=(t,o)=>{e.r[t]=[new Promise(((e,t)=>o=e)),o]},e.set=(t,o,s)=>{(s=e.r[t])&&s[1](o),e.r[t]=[o]},e.unset=t=>{delete e.r[t]},e.load=(t,o)=>{if(o=e.r[t])return o[0]} 83 | } 84 | 85 | export const wrap = (textarea: HTMLTextAreaElement, options: Ink.Options = {}) => { 86 | const replacement =
as HTMLDivElement 87 | const doc = textarea.value 88 | 89 | textarea.after(replacement) 90 | textarea.style.display = 'none' 91 | 92 | const instance = render(replacement, { doc, ...options }) 93 | 94 | if (textarea.form) { 95 | textarea.form.addEventListener('submit', () => { 96 | textarea.value = instance.getDoc() 97 | }) 98 | } 99 | 100 | return instance 101 | } 102 | 103 | export default ink 104 | -------------------------------------------------------------------------------- /src/instance.ts: -------------------------------------------------------------------------------- 1 | import { 2 | destroy, 3 | focus, 4 | format, 5 | getDoc, 6 | insert, 7 | load, 8 | options, 9 | reconfigure, 10 | select, 11 | selections, 12 | update, 13 | wrap, 14 | } from '/src/api' 15 | import { awaitable } from '/src/utils/awaitable' 16 | import type * as Ink from '/types/ink' 17 | import type InkInternal from '/types/internal' 18 | 19 | export const makeInstance = (store: InkInternal.Store): Ink.AwaitableInstance => { 20 | const instance = { 21 | destroy: destroy.bind(undefined, store), 22 | focus: focus.bind(undefined, store), 23 | format: format.bind(undefined, store), 24 | getDoc: getDoc.bind(undefined, store), 25 | insert: insert.bind(undefined, store), 26 | load: load.bind(undefined, store), 27 | options: options.bind(undefined, store), 28 | reconfigure: reconfigure.bind(undefined, store), 29 | select: select.bind(undefined, store), 30 | selections: selections.bind(undefined, store), 31 | update: update.bind(undefined, store), 32 | wrap: wrap.bind(undefined, store), 33 | } 34 | 35 | return awaitable(instance, (resolve, reject) => { 36 | try { 37 | const [state] = store 38 | 39 | // Ensure all other queued tasks are finished before resolving. 40 | state().workQueue.enqueue(() => resolve(instance)) 41 | } catch (error: any) { 42 | reject(error) 43 | } 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /src/markdown.ts: -------------------------------------------------------------------------------- 1 | import { markdown as markdownExtension, markdownLanguage } from '@codemirror/lang-markdown' 2 | import { languages as baseLanguages } from '@codemirror/language-data' 3 | import { Compartment } from '@codemirror/state' 4 | import { type MarkdownExtension } from '@lezer/markdown' 5 | import { buildVendorUpdates } from '/src/extensions' 6 | import { filterPlugins, partitionPlugins } from '/src/utils/options' 7 | import { type InkInternal, type OptionsResolved, pluginTypes } from '/types' 8 | 9 | const makeExtension = ([state, setState]: InkInternal.Store) => { 10 | const baseExtensions = [] as MarkdownExtension[] 11 | const [lazyExtensions, extensions] = filterExtensions(state().options) 12 | const [lazyLanguages, languages] = filterLanguages(state().options) 13 | 14 | if (Math.max(lazyExtensions.length, lazyLanguages.length) > 0) { 15 | state().workQueue.enqueue(async () => { 16 | const effects = await buildVendorUpdates([state, setState]) 17 | 18 | state().editor.dispatch({ effects }) 19 | }) 20 | } 21 | 22 | return markdownExtension({ 23 | base: markdownLanguage, 24 | codeLanguages: [...baseLanguages, ...languages], 25 | extensions: [...baseExtensions, ...extensions], 26 | }) 27 | } 28 | 29 | const filterExtensions = (options: OptionsResolved) => { 30 | return partitionPlugins(filterPlugins(pluginTypes.grammar, options)) 31 | } 32 | 33 | const filterLanguages = (options: OptionsResolved) => { 34 | return partitionPlugins(filterPlugins(pluginTypes.language, options)) 35 | } 36 | 37 | const updateExtension = async ([state]: InkInternal.Store) => { 38 | const baseExtensions = [] as MarkdownExtension[] 39 | const extensions = await Promise.all(filterPlugins(pluginTypes.grammar, state().options)) 40 | const languages = await Promise.all(filterPlugins(pluginTypes.language, state().options)) 41 | 42 | return markdownExtension({ 43 | base: markdownLanguage, 44 | codeLanguages: [...baseLanguages, ...languages], 45 | extensions: [...baseExtensions, ...extensions], 46 | }) 47 | } 48 | 49 | export const markdown = (): InkInternal.Extension => { 50 | const compartment = new Compartment() 51 | 52 | return { 53 | compartment, 54 | initialValue: (store: InkInternal.Store) => { 55 | return compartment.of(makeExtension(store)) 56 | }, 57 | reconfigure: async (store: InkInternal.Store) => { 58 | return compartment.reconfigure(await updateExtension(store)) 59 | }, 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import { createSignal } from 'solid-js' 2 | import { katex } from '/plugins/katex' 3 | import { createExtensions } from '/src/extensions' 4 | import { override } from '/src/utils/merge' 5 | import { makeQueue } from '/src/utils/queue' 6 | import type { Options } from '/types/ink' 7 | import type InkInternal from '/types/internal' 8 | import * as InkValues from '/types/values' 9 | import { createElement } from './ui/utils' 10 | 11 | export const blankState = (): InkInternal.StateResolved => { 12 | const options = { 13 | doc: '', 14 | files: { 15 | clipboard: false, 16 | dragAndDrop: false, 17 | handler: () => {}, 18 | injectMarkup: true, 19 | types: ['image/*'], 20 | }, 21 | hooks: { 22 | afterUpdate: () => {}, 23 | beforeUpdate: () => {}, 24 | }, 25 | interface: { 26 | appearance: InkValues.Appearance.Auto, 27 | attribution: true, 28 | autocomplete: false, 29 | images: false, 30 | lists: false, 31 | readonly: false, 32 | spellcheck: true, 33 | toolbar: false, 34 | }, 35 | katex: false, 36 | keybindings: { 37 | // Todo: Set these to false by default. https://codemirror.net/examples/tab 38 | tab: true, 39 | shiftTab: true, 40 | }, 41 | lists: false, 42 | placeholder: '', 43 | plugins: [ 44 | katex(), 45 | ], 46 | readability: false, 47 | search: true, 48 | selections: [], 49 | toolbar: { 50 | bold: true, 51 | code: true, 52 | codeBlock: true, 53 | heading: true, 54 | image: true, 55 | italic: true, 56 | link: true, 57 | list: true, 58 | orderedList: true, 59 | quote: true, 60 | taskList: true, 61 | upload: false, 62 | }, 63 | // This value overrides both `tab` and `shiftTab` keybindings. 64 | trapTab: undefined, 65 | vim: false, 66 | } 67 | 68 | return { 69 | doc: '', 70 | editor: {} as InkInternal.Editor, 71 | extensions: createExtensions(), 72 | options, 73 | root: createElement(), 74 | target: createElement(), 75 | workQueue: makeQueue(), 76 | } 77 | } 78 | 79 | export const makeState = (partialState: InkInternal.State): InkInternal.StateResolved => { 80 | return override(blankState(), partialState) 81 | } 82 | 83 | export const makeStore = (options: Options, overrides: InkInternal.State = {}): InkInternal.Store => { 84 | const [state, setState] = createSignal(makeState({ ...overrides, doc: options.doc || '', options })) 85 | 86 | return [state, setState] 87 | } 88 | -------------------------------------------------------------------------------- /src/ui/app.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'solid-js' 2 | import type { Component, JSX } from 'solid-js' 3 | import type InkInternal from '/types/internal' 4 | import { blankState } from '../store' 5 | import { Root } from './components/root' 6 | 7 | const AppContext = createContext([() => blankState(), value => (typeof value === 'function' ? value(blankState()) : value)]) 8 | const AppProvider: Component<{ children: JSX.Element, store: InkInternal.Store }> = (props) => { 9 | return ( 10 | // eslint-disable-next-line solid/reactivity 11 | 12 | {props.children} 13 | 14 | ) 15 | } 16 | 17 | export const useStore = () => { 18 | return useContext(AppContext) 19 | } 20 | 21 | export const App: Component<{ store: InkInternal.Store, target?: HTMLElement }> = (props) => { 22 | return ( 23 | 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/ui/components/button/index.tsx: -------------------------------------------------------------------------------- 1 | import type { Component, JSX } from 'solid-js' 2 | 3 | export const Button: Component<{ children: JSX.Element, onclick: JSX.EventHandler }> = (props) => { 4 | return ( 5 | 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/ui/components/details/index.tsx: -------------------------------------------------------------------------------- 1 | import { type Component, Show } from 'solid-js' 2 | import { toHuman } from '/src/utils/readability' 3 | import type InkInternal from '/types/internal' 4 | import { useStore } from '../../app' 5 | 6 | export const Details: Component<{ store: InkInternal.Store }> = () => { 7 | const [state] = useStore() 8 | 9 | return ( 10 |
11 |
12 |
13 | 14 |
15 | { toHuman(state().doc) } 16 |
17 |
18 | 19 |  | 20 | 21 | 22 |
23 |  powered by ink-mde 24 |
25 |
26 |
27 |
28 |
29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/ui/components/drop_zone/index.tsx: -------------------------------------------------------------------------------- 1 | import type { Component } from 'solid-js' 2 | import { For, Show, createSignal, onCleanup, onMount } from 'solid-js' 3 | import { insert } from '/src/api' 4 | import { useStore } from '../../app' 5 | import styles from './styles.css?inline' 6 | 7 | export const DropZone: Component = () => { 8 | const [depth, setDepth] = createSignal(0) 9 | const [files, setFiles] = createSignal([]) 10 | const [isLoading, setIsLoading] = createSignal(false) 11 | const [isVisible, setIsVisible] = createSignal(false) 12 | const [state, setState] = useStore() 13 | 14 | const closeModal = () => { 15 | setIsVisible(false) 16 | } 17 | 18 | const dropOnZone = (event: DragEvent) => { 19 | if (state().options.files.dragAndDrop) { 20 | event.preventDefault() 21 | event.stopPropagation() 22 | 23 | const transfer = event.dataTransfer 24 | 25 | if (transfer?.files) { 26 | uploadFiles(transfer.files) 27 | } else { 28 | setDepth(0) 29 | setIsVisible(false) 30 | setFiles([]) 31 | } 32 | } 33 | } 34 | 35 | const onDragEnter = (event: DragEvent) => { 36 | if (state().options.files.dragAndDrop) { 37 | event.preventDefault() 38 | 39 | setDepth(depth() + 1) 40 | setIsVisible(true) 41 | } 42 | } 43 | 44 | const onDragLeave = (event: DragEvent) => { 45 | if (state().options.files.dragAndDrop) { 46 | event.preventDefault() 47 | 48 | setDepth(depth() - 1) 49 | 50 | if (depth() === 0) 51 | setIsVisible(false) 52 | } 53 | } 54 | 55 | const onDragOver = (event: DragEvent) => { 56 | if (state().options.files.dragAndDrop) { 57 | event.preventDefault() 58 | 59 | setIsVisible(true) 60 | } 61 | } 62 | 63 | const onDrop = (event: DragEvent) => { 64 | if (state().options.files.dragAndDrop) { 65 | event.preventDefault() 66 | 67 | setDepth(0) 68 | setIsVisible(false) 69 | } 70 | } 71 | 72 | const onPaste = (event: ClipboardEvent) => { 73 | if (state().options.files.clipboard) { 74 | event.preventDefault() 75 | 76 | const transfer = event.clipboardData 77 | 78 | if (transfer?.files && transfer.files.length > 0) 79 | uploadFiles(transfer.files) 80 | } 81 | } 82 | 83 | const uploadFiles = (userFiles: FileList) => { 84 | Array.from(userFiles).forEach((file) => { 85 | setFiles([...files(), file]) 86 | }) 87 | 88 | setIsLoading(true) 89 | setIsVisible(true) 90 | 91 | Promise.resolve(state().options.files.handler(userFiles)).then((url) => { 92 | if (state().options.files.injectMarkup && url) { 93 | const markup = `![](${url})` 94 | 95 | insert([state, setState], markup) 96 | } 97 | }).finally(() => { 98 | setDepth(0) 99 | setIsLoading(false) 100 | setIsVisible(false) 101 | setFiles([]) 102 | }) 103 | } 104 | 105 | onMount(() => { 106 | document.addEventListener('dragenter', onDragEnter) 107 | document.addEventListener('dragleave', onDragLeave) 108 | document.addEventListener('dragover', onDragOver) 109 | document.addEventListener('drop', onDrop) 110 | state().root.addEventListener('paste', onPaste) 111 | }) 112 | 113 | onCleanup(() => { 114 | document.removeEventListener('dragenter', onDragEnter) 115 | document.removeEventListener('dragleave', onDragLeave) 116 | document.removeEventListener('dragover', onDragOver) 117 | document.removeEventListener('drop', onDrop) 118 | state().root.removeEventListener('paste', onPaste) 119 | }) 120 | 121 | return ( 122 |
123 | 55 | 56 | 57 |
58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /vue/server.ts: -------------------------------------------------------------------------------- 1 | // https://vitejs.dev/guide/ssr.html 2 | import fs from 'node:fs' 3 | import path from 'node:path' 4 | import { fileURLToPath } from 'node:url' 5 | import express from 'express' 6 | import { createServer as createViteServer } from 'vite' 7 | import { externalizeDeps } from 'vite-plugin-externalize-deps' 8 | 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 10 | 11 | async function createServer() { 12 | const app = express() 13 | 14 | // Create Vite server in middleware mode and configure the app type as 15 | // 'custom', disabling Vite's own HTML serving logic so parent server 16 | // can take control 17 | const vite = await createViteServer({ 18 | appType: 'custom', 19 | plugins: [ 20 | externalizeDeps(), 21 | ], 22 | root: path.resolve(__dirname), 23 | server: { 24 | middlewareMode: true, 25 | }, 26 | }) 27 | 28 | // use vite's connect instance as middleware 29 | // if you use your own express router (express.Router()), you should use router.use 30 | app.use(vite.middlewares) 31 | 32 | app.use('*', async (req, res, next) => { 33 | const url = req.originalUrl 34 | 35 | try { 36 | // 1. Read index.html 37 | let template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8') 38 | 39 | // 2. Apply Vite HTML transforms. This injects the Vite HMR client, and 40 | // also applies HTML transforms from Vite plugins, e.g. global preambles 41 | // from @vitejs/plugin-react 42 | template = await vite.transformIndexHtml(url, template) 43 | 44 | // 3. Load the server entry. vite.ssrLoadModule automatically transforms 45 | // your ESM source code to be usable in Node.js! There is no bundling 46 | // required, and provides efficient invalidation similar to HMR. 47 | const { render } = await vite.ssrLoadModule('/dev/server.ts') 48 | 49 | // 4. render the app HTML. This assumes entry-server.js's exported `render` 50 | // function calls appropriate framework SSR APIs, 51 | // e.g. ReactDOMServer.renderToString() 52 | const appHtml = await render() 53 | 54 | // 5. Inject the app-rendered HTML into the template. 55 | const html = template.replace('', appHtml) 56 | 57 | // 6. Send the rendered HTML back. 58 | res.status(200).set({ 'Content-Type': 'text/html' }).end(html) 59 | // @ts-expect-error Allow use of the Error type. 60 | } catch (e: Error) { 61 | // If an error is caught, let Vite fix the stack trace so it maps back to 62 | // your actual source code. 63 | vite.ssrFixStacktrace(e) 64 | next(e) 65 | } 66 | }) 67 | 68 | app.listen(5173, () => { 69 | // eslint-disable-next-line no-console 70 | console.log('http://localhost:5173') 71 | }) 72 | } 73 | 74 | createServer() 75 | -------------------------------------------------------------------------------- /vue/src/InkMde.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 78 | -------------------------------------------------------------------------------- /vue/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './InkMde.vue' 2 | export { default as InkMde } from './InkMde.vue' 3 | -------------------------------------------------------------------------------- /vue/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "declarationDir": "./tmp/types", 6 | "declarationMap": true, 7 | "emitDeclarationOnly": true, 8 | "noEmit": false, 9 | "paths": { 10 | "ink-mde": [ 11 | "../" 12 | ] 13 | }, 14 | "rootDir": "." 15 | }, 16 | "include": [ 17 | "../env.d.ts", 18 | "./**/*" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /vue/types.config.js: -------------------------------------------------------------------------------- 1 | import { dirname, join } from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | import { defineConfig } from 'rollup' 4 | import dts from 'rollup-plugin-dts' 5 | 6 | const root = dirname(fileURLToPath(import.meta.url)) 7 | 8 | export default defineConfig({ 9 | input: join(root, './tmp/types/src/index.d.ts'), 10 | output: [ 11 | { 12 | file: join(root, './dist/client.d.ts'), 13 | format: 'es', 14 | }, 15 | { 16 | file: join(root, './dist/index.d.ts'), 17 | format: 'es', 18 | }, 19 | ], 20 | plugins: [ 21 | dts(), 22 | ], 23 | }) 24 | -------------------------------------------------------------------------------- /vue/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { dirname, join } from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | import vue from '@vitejs/plugin-vue' 4 | import { defineConfig } from 'vite' 5 | import { externalizeDeps } from 'vite-plugin-externalize-deps' 6 | 7 | const root = dirname(fileURLToPath(import.meta.url)) 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig(({ isSsrBuild }) => { 11 | return { 12 | build: { 13 | emptyOutDir: false, 14 | lib: { 15 | entry: join(root, './src/index.ts'), 16 | fileName: isSsrBuild ? 'index' : 'client', 17 | }, 18 | rollupOptions: { 19 | external: [ 20 | /^ink-mde(?:\/.+)?$/, 21 | /^vue(?:\/.+)?$/, 22 | ], 23 | output: [ 24 | { 25 | esModule: true, 26 | exports: 'named', 27 | format: 'es', 28 | globals: { 29 | vue: 'Vue', 30 | }, 31 | }, 32 | { 33 | exports: 'named', 34 | format: 'cjs', 35 | inlineDynamicImports: true, 36 | interop: 'esModule', 37 | globals: { 38 | vue: 'Vue', 39 | }, 40 | }, 41 | ], 42 | }, 43 | sourcemap: true, 44 | }, 45 | plugins: [ 46 | externalizeDeps(), 47 | vue(), 48 | ], 49 | root, 50 | server: { 51 | port: 5173, 52 | }, 53 | } 54 | }) 55 | --------------------------------------------------------------------------------