diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e718f89 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,64 @@ +name: Node.js CI/CD Pipeline + +on: + push: + branches: [master] + pull_request: + branches: [master] + workflow_dispatch: + +jobs: + build-obsidian: + runs-on: ubuntu-latest + steps: + - name: checkout repo + uses: actions/checkout@v4 + - name: env use node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Install Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + - name: build + run: | + bun install + bun run build + - name: upload build artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: build-artifact + path: | + manifest.json + main.js + styles.css + + build-obsidian-min: + runs-on: ubuntu-latest + steps: + - name: checkout repo + uses: actions/checkout@v4 + - name: env use node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Install Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + - name: build + run: | + bun install + bun run build-min + mv dist-min/main.js main.js + - name: upload build artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: build-artifact-min + path: | + manifest.json + main.js + styles.css diff --git a/automation/build/esbuild.config.min.ts b/automation/build/esbuild.config.min.ts new file mode 100644 index 0000000..c981461 --- /dev/null +++ b/automation/build/esbuild.config.min.ts @@ -0,0 +1,56 @@ +import builtins from 'builtin-modules'; +import esbuild from 'esbuild'; +import { getBuildBanner } from 'build/buildBanner'; +import { nodeModulesPolyfillPlugin } from 'esbuild-plugins-node-modules-polyfill'; + +const banner = getBuildBanner('Release Build', version => version); + +const build = await esbuild.build({ + banner: { + js: banner, + }, + entryPoints: ['src/main.min.ts'], + bundle: true, + external: [ + 'obsidian', + 'electron', + '@codemirror/autocomplete', + '@codemirror/collab', + '@codemirror/commands', + '@codemirror/language', + '@codemirror/lint', + '@codemirror/search', + '@codemirror/state', + '@codemirror/view', + '@lezer/common', + '@lezer/highlight', + '@lezer/lr', + ...builtins, + 'shiki', // [!code hl] + ], + format: 'cjs', + target: 'es2018', + logLevel: 'info', + sourcemap: false, + treeShaking: true, + outfile: 'dist-min/main.js', // [!code hl] + minify: true, + metafile: true, + define: { + MB_GLOBAL_CONFIG_DEV_BUILD: 'false', + }, + plugins: [ + nodeModulesPolyfillPlugin({ + modules: { + fs: true, + path: true, + url: true, + }, + }), + ], +}); + +const file = Bun.file('meta.txt'); +await Bun.write(file, JSON.stringify(build.metafile, null, '\t')); + +process.exit(0); diff --git a/bun.lock b/bun.lock index cc2e2ef..1ce7794 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,8 @@ "": { "name": "shiki-highlighter", "devDependencies": { + "@codemirror/basic-setup": "^0.20.0", + "@codemirror/lang-markdown": "^6.3.2", "@codemirror/language": "^6.11.0", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.36.8", @@ -17,8 +19,10 @@ "@happy-dom/global-registrator": "^17.4.7", "@lemons_dev/parsinom": "^0.0.12", "@lezer/common": "^1.2.3", + "@shikijs/transformers": "^3.4.2", "@tsconfig/svelte": "^5.0.4", "@types/bun": "^1.2.13", + "@types/prismjs": "^1.26.5", "builtin-modules": "^5.0.0", "esbuild": "^0.25.4", "esbuild-plugin-copy-watch": "^2.3.1", @@ -38,8 +42,26 @@ }, }, "packages": { + "@codemirror/autocomplete": ["@codemirror/autocomplete@0.20.3", "", { "dependencies": { "@codemirror/language": "^0.20.0", "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0", "@lezer/common": "^0.16.0" } }, "sha512-lYB+NPGP+LEzAudkWhLfMxhTrxtLILGl938w+RcFrGdrIc54A+UgmCoz+McE3IYRFp4xyQcL4uFJwo+93YdgHw=="], + + "@codemirror/basic-setup": ["@codemirror/basic-setup@0.20.0", "", { "dependencies": { "@codemirror/autocomplete": "^0.20.0", "@codemirror/commands": "^0.20.0", "@codemirror/language": "^0.20.0", "@codemirror/lint": "^0.20.0", "@codemirror/search": "^0.20.0", "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0" } }, "sha512-W/ERKMLErWkrVLyP5I8Yh8PXl4r+WFNkdYVSzkXYPQv2RMPSkWpr2BgggiSJ8AHF/q3GuApncDD8I4BZz65fyg=="], + + "@codemirror/commands": ["@codemirror/commands@0.20.0", "", { "dependencies": { "@codemirror/language": "^0.20.0", "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0", "@lezer/common": "^0.16.0" } }, "sha512-v9L5NNVA+A9R6zaFvaTbxs30kc69F6BkOoiEbeFw4m4I0exmDEKBILN6mK+GksJtvTzGBxvhAPlVFTdQW8GB7Q=="], + + "@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="], + + "@codemirror/lang-html": ["@codemirror/lang-html@6.4.9", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.0" } }, "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q=="], + + "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.4", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA=="], + + "@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.3.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-c/5MYinGbFxYl4itE9q/rgN/sMTjOr8XL5OWnC+EaRMLfCbVUmmubTJfdgpfcSS2SCaT7b+Q+xi3l6CgoE+BsA=="], + "@codemirror/language": ["@codemirror/language@6.11.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.1.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-A7+f++LodNNc1wGgoRDTt78cOwWm9KVezApgjOMp1W4hM0898nsqBXwF+sbePE7ZRcjN7Sa1Z5m2oN27XkmEjQ=="], + "@codemirror/lint": ["@codemirror/lint@0.20.3", "", { "dependencies": { "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.2", "crelt": "^1.0.5" } }, "sha512-06xUScbbspZ8mKoODQCEx6hz1bjaq9m8W8DxdycWARMiiX1wMtfCh/MoHpaL7ws/KUMwlsFFfp2qhm32oaCvVA=="], + + "@codemirror/search": ["@codemirror/search@0.20.1", "", { "dependencies": { "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0", "crelt": "^1.0.5" } }, "sha512-ROe6gRboQU5E4z6GAkNa2kxhXqsGNbeLEisbvzbOeB7nuDYXUZ70vGIgmqPu0tB+1M3F9yWk6W8k2vrFpJaD4Q=="], + "@codemirror/state": ["@codemirror/state@6.5.2", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA=="], "@codemirror/view": ["@codemirror/view@6.36.8", "", { "dependencies": { "@codemirror/state": "^6.5.0", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-yoRo4f+FdnD01fFt4XpfpMCcCAo9QvZOtbrXExn4SqzH32YC6LgzqxfLZw/r6Ge65xyY03mK/UfUqrVw1gFiFg=="], @@ -142,10 +164,18 @@ "@lezer/common": ["@lezer/common@1.2.3", "", {}, "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA=="], + "@lezer/css": ["@lezer/css@1.2.1", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-2F5tOqzKEKbCUNraIXc0f6HKeyKlmMWJnBB0i4XW6dJgssrZO/YlZ2pY5xgyqDleqqhiNJ3dQhbrV2aClZQMvg=="], + "@lezer/highlight": ["@lezer/highlight@1.2.1", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA=="], + "@lezer/html": ["@lezer/html@1.3.10", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w=="], + + "@lezer/javascript": ["@lezer/javascript@1.5.1", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw=="], + "@lezer/lr": ["@lezer/lr@1.4.2", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA=="], + "@lezer/markdown": ["@lezer/markdown@1.4.3", "", { "dependencies": { "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-kfw+2uMrQ/wy/+ONfrH83OkdFNM0ye5Xq96cLlaCy7h5UT9FO54DU4oRoIc0CSBh5NWmWuiIJA7NGLMJbQ+Oxg=="], + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -164,6 +194,8 @@ "@shikijs/themes": ["@shikijs/themes@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2" } }, "sha512-qAEuAQh+brd8Jyej2UDDf+b4V2g1Rm8aBIdvt32XhDPrHvDkEnpb7Kzc9hSuHUxz0Iuflmq7elaDuQAP9bHIhg=="], + "@shikijs/transformers": ["@shikijs/transformers@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/types": "3.4.2" } }, "sha512-I5baLVi/ynLEOZoWSAMlACHNnG+yw5HDmse0oe+GW6U1u+ULdEB3UHiVWaHoJSSONV7tlcVxuaMy74sREDkSvg=="], + "@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], @@ -184,6 +216,8 @@ "@types/node": ["@types/node@20.17.48", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-KpSfKOHPsiSC4IkZeu2LsusFwExAIVGkhG1KkbaBMLwau0uMhj0fCrvyg9ddM2sAvd+gtiBJLir4LAw1MNMIaw=="], + "@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="], + "@types/tern": ["@types/tern@0.23.9", "", { "dependencies": { "@types/estree": "*" } }, "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -256,6 +290,8 @@ "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css-selector-parser": ["css-selector-parser@3.1.2", "", {}, "sha512-WfUcL99xWDs7b3eZPoRszWVfbNo8ErCF15PTvVROjkShGlAfjIkG6hlfj/sl6/rfo5Q9x9ryJ3VqVnAZDA+gcw=="], @@ -564,6 +600,46 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@codemirror/autocomplete/@codemirror/language": ["@codemirror/language@0.20.2", "", { "dependencies": { "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0", "@lezer/common": "^0.16.0", "@lezer/highlight": "^0.16.0", "@lezer/lr": "^0.16.0", "style-mod": "^4.0.0" } }, "sha512-WB3Bnuusw0xhVvhBocieYKwJm04SOk5bPoOEYksVHKHcGHFOaYaw+eZVxR4gIqMMcGzOIUil0FsCmFk8yrhHpw=="], + + "@codemirror/autocomplete/@codemirror/state": ["@codemirror/state@0.20.1", "", {}, "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ=="], + + "@codemirror/autocomplete/@codemirror/view": ["@codemirror/view@0.20.7", "", { "dependencies": { "@codemirror/state": "^0.20.0", "style-mod": "^4.0.0", "w3c-keyname": "^2.2.4" } }, "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ=="], + + "@codemirror/autocomplete/@lezer/common": ["@lezer/common@0.16.1", "", {}, "sha512-qPmG7YTZ6lATyTOAWf8vXE+iRrt1NJd4cm2nJHK+v7X9TsOF6+HtuU/ctaZy2RCrluxDb89hI6KWQ5LfQGQWuA=="], + + "@codemirror/basic-setup/@codemirror/language": ["@codemirror/language@0.20.2", "", { "dependencies": { "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0", "@lezer/common": "^0.16.0", "@lezer/highlight": "^0.16.0", "@lezer/lr": "^0.16.0", "style-mod": "^4.0.0" } }, "sha512-WB3Bnuusw0xhVvhBocieYKwJm04SOk5bPoOEYksVHKHcGHFOaYaw+eZVxR4gIqMMcGzOIUil0FsCmFk8yrhHpw=="], + + "@codemirror/basic-setup/@codemirror/state": ["@codemirror/state@0.20.1", "", {}, "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ=="], + + "@codemirror/basic-setup/@codemirror/view": ["@codemirror/view@0.20.7", "", { "dependencies": { "@codemirror/state": "^0.20.0", "style-mod": "^4.0.0", "w3c-keyname": "^2.2.4" } }, "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ=="], + + "@codemirror/commands/@codemirror/language": ["@codemirror/language@0.20.2", "", { "dependencies": { "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0", "@lezer/common": "^0.16.0", "@lezer/highlight": "^0.16.0", "@lezer/lr": "^0.16.0", "style-mod": "^4.0.0" } }, "sha512-WB3Bnuusw0xhVvhBocieYKwJm04SOk5bPoOEYksVHKHcGHFOaYaw+eZVxR4gIqMMcGzOIUil0FsCmFk8yrhHpw=="], + + "@codemirror/commands/@codemirror/state": ["@codemirror/state@0.20.1", "", {}, "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ=="], + + "@codemirror/commands/@codemirror/view": ["@codemirror/view@0.20.7", "", { "dependencies": { "@codemirror/state": "^0.20.0", "style-mod": "^4.0.0", "w3c-keyname": "^2.2.4" } }, "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ=="], + + "@codemirror/commands/@lezer/common": ["@lezer/common@0.16.1", "", {}, "sha512-qPmG7YTZ6lATyTOAWf8vXE+iRrt1NJd4cm2nJHK+v7X9TsOF6+HtuU/ctaZy2RCrluxDb89hI6KWQ5LfQGQWuA=="], + + "@codemirror/lang-css/@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.6", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg=="], + + "@codemirror/lang-html/@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.6", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg=="], + + "@codemirror/lang-javascript/@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.6", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg=="], + + "@codemirror/lang-javascript/@codemirror/lint": ["@codemirror/lint@6.8.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA=="], + + "@codemirror/lang-markdown/@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.6", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg=="], + + "@codemirror/lint/@codemirror/state": ["@codemirror/state@0.20.1", "", {}, "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ=="], + + "@codemirror/lint/@codemirror/view": ["@codemirror/view@0.20.7", "", { "dependencies": { "@codemirror/state": "^0.20.0", "style-mod": "^4.0.0", "w3c-keyname": "^2.2.4" } }, "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ=="], + + "@codemirror/search/@codemirror/state": ["@codemirror/state@0.20.1", "", {}, "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ=="], + + "@codemirror/search/@codemirror/view": ["@codemirror/view@0.20.7", "", { "dependencies": { "@codemirror/state": "^0.20.0", "style-mod": "^4.0.0", "w3c-keyname": "^2.2.4" } }, "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], @@ -578,6 +654,20 @@ "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "@codemirror/autocomplete/@codemirror/language/@lezer/highlight": ["@lezer/highlight@0.16.0", "", { "dependencies": { "@lezer/common": "^0.16.0" } }, "sha512-iE5f4flHlJ1g1clOStvXNLbORJoiW4Kytso6ubfYzHnaNo/eo5SKhxs4wv/rtvwZQeZrK3we8S9SyA7OGOoRKQ=="], + + "@codemirror/autocomplete/@codemirror/language/@lezer/lr": ["@lezer/lr@0.16.3", "", { "dependencies": { "@lezer/common": "^0.16.0" } }, "sha512-pau7um4eAw94BEuuShUIeQDTf3k4Wt6oIUOYxMmkZgDHdqtIcxWND4LRxi8nI9KuT4I1bXQv67BCapkxt7Ywqw=="], + + "@codemirror/basic-setup/@codemirror/language/@lezer/common": ["@lezer/common@0.16.1", "", {}, "sha512-qPmG7YTZ6lATyTOAWf8vXE+iRrt1NJd4cm2nJHK+v7X9TsOF6+HtuU/ctaZy2RCrluxDb89hI6KWQ5LfQGQWuA=="], + + "@codemirror/basic-setup/@codemirror/language/@lezer/highlight": ["@lezer/highlight@0.16.0", "", { "dependencies": { "@lezer/common": "^0.16.0" } }, "sha512-iE5f4flHlJ1g1clOStvXNLbORJoiW4Kytso6ubfYzHnaNo/eo5SKhxs4wv/rtvwZQeZrK3we8S9SyA7OGOoRKQ=="], + + "@codemirror/basic-setup/@codemirror/language/@lezer/lr": ["@lezer/lr@0.16.3", "", { "dependencies": { "@lezer/common": "^0.16.0" } }, "sha512-pau7um4eAw94BEuuShUIeQDTf3k4Wt6oIUOYxMmkZgDHdqtIcxWND4LRxi8nI9KuT4I1bXQv67BCapkxt7Ywqw=="], + + "@codemirror/commands/@codemirror/language/@lezer/highlight": ["@lezer/highlight@0.16.0", "", { "dependencies": { "@lezer/common": "^0.16.0" } }, "sha512-iE5f4flHlJ1g1clOStvXNLbORJoiW4Kytso6ubfYzHnaNo/eo5SKhxs4wv/rtvwZQeZrK3we8S9SyA7OGOoRKQ=="], + + "@codemirror/commands/@codemirror/language/@lezer/lr": ["@lezer/lr@0.16.3", "", { "dependencies": { "@lezer/common": "^0.16.0" } }, "sha512-pau7um4eAw94BEuuShUIeQDTf3k4Wt6oIUOYxMmkZgDHdqtIcxWND4LRxi8nI9KuT4I1bXQv67BCapkxt7Ywqw=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..98fafb6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,84 @@ +# More Document + +version: v0.5.1 + +## SettingPanel Document + +### Rendering engine + +Shiki, PrismJS, CodeMirror + +- Shiki: A powerful code highlighting engine. + - More powerful functions, more themes and plugins + - Plugins: meta annotations, annotated annotations. Line highlighting, word highlighting, differentiated annotation, warning/error annotation + - Theme: Nearly 80 color schemes: You can visually select them at https://textmate-grammars-themes.netlify.app + - *The min version does not include this library and the engine cannot be selected* +- PrismJS: The rendering engine that Obsidian uses by default in reading mode. + - When choosing this one, you can also select the min version of this plugin, which has a smaller plugin size and a faster loading speed + - It can be color-matched with code using obsidian themes and can be used in conjunction with some other obsidian stylization plugins +- CodeMirror: Obsidian is the default rendering engine used in real-time mode. The current plugin is not supported + - It is suitable for real-time rendering and has acceptable performance + - However, the code analysis is rather rough, with a small number of highlighting layers and a poor effect + +### Rendering method + +- textarea (default) + - Advantage: + Allows real-time editing and offers a Typora-like WYSIWYG experience + Support editing annotation-type highlighting + The new version of Obsidian's md table within block editing uses this approach. (However, the ob table editing does not trigger a re-rendering.) + - Disadvantage: + In principle, textarea and pre are perfectly overlapped together, but they are prone to incomplete overlap due to the influence of themes and styles +- pre + - Disadvantage: + Real-time editing is not allowed. The rendering effect is more similar to the textarea method +- editable pre + - Advantage: + Allows real-time editing and offers a Typora-like WYSIWYG experience + In principle, it is `code[contenteditable='true']` + - Disadvantage: + The cursor position needs to be handled manually in the program + *No support editing annotation-type highlighting* +- codemirror + - Disadvantage: + The only supported method for V0.5.0 and earlier versions, which does not allow real-time editing + +> [!warning] +> +> If a real-time editable solution is chosen, it is best to use it when the warehouse is regularly backed up to avoid unexpected situations + +### AutoSave method + +- onchange + - Advantage: + Great performance. + There is no need to manage the cursor position manually + - Disadvantage: + Delay save, change will loss if: the program crashes suddenly. when cursor in codeblock, switch to readmode or close window/tab +- oninput + - Advantage: + Save immediately, data is more secure. + The new version of Obsidian's md table within block editing uses this approach. + - Disadvantage: + Worse performance? The code block needs to be recreated every time it is modified + The cursor position needs to be handled manually. Debounce manually. + It is necessary to pay attention to the input method issue. The `oninput` will also be triggered during the input candidate stage + +## Shiki Extend Sytax + +see https://shiki.style/packages/transformers for detail + +This is a simple summary of grammar: + +- notaion + - diff: `// [!code ++]` `// [!code --]` + - highlight: `// [!code hl]` `// [!code highlight]` + - word highlight: `// [!code word::]` `// [!code word:Hello:1]` + - focus: `// [!code focus]` + - error level: `// [!code error]` `// [!code warning]` + - (mul line): `// [!code highlight:3]` +- meta + - highlight: `{1,3-4}` + - word highlight `//` `/Hello/` + +example: see [../README.md](../README.md) or [Shiki document](https://shiki.style/packages/transformers) diff --git a/docs/README.zh.md b/docs/README.zh.md new file mode 100644 index 0000000..76b6a81 --- /dev/null +++ b/docs/README.zh.md @@ -0,0 +1,84 @@ +# 更多文档 + +version: v0.5.1 + +## 设置面板文档 + +### 渲染引擎 + +Shiki, PrismJS,CodeMirror + +- Shiki: 一个强大的代码高亮引擎。 + - 功能更加强大,更多主题和插件 + - 插件: meta标注、注释型标注。行高亮、单词高亮、差异化标注、警告/错误标注 + - 主题:近80种配色方案:你可以在 https://textmate-grammars-themes.netlify.app 中可视化选择 + - *min版不包含该库,无法选用该引擎* +- PrismJS: Obsidian默认在阅读模式中使用的渲染引擎。 + - 当选择这个的时候,你也可以选用min版本的本插件,拥有更小的插件体积和更快的加载速度 + - 可以与使用obsidian主题的代码配色,可以与一些其他的obsidian风格化插件配合 +- CodeMirror: Obsidian默认在实时模式中使用的渲染引擎。当前插件不支持 + - 适合实时渲染,性能尚可 + - 但代码分析比较粗糙,高亮层数少,效果较差 + +### 渲染方式 + +- textarea (默认) + - 优点: + 允许实时编辑,typora般的所见即所得的体验 + 支持编辑注释型高亮 + 同为块内编辑的obsidian新版本md表格,采用的是这种方式 (但ob表格编辑时不触发重渲染) + - 缺点: + 原理上是将textarea和pre完美重叠在一起,但容易受主题和样式影响导致不完全重叠 +- pre + - 缺点: + 不允许实时编辑 +- editable pre + - 优点: + 允许实时编辑,typora般的所见即所得的体验 + 原理上是 `code[contenteditable='true']` + - 缺点: + 程序上需要手动处理光标位置 + *不支持实时编辑注释型高亮* +- codemirror + - 缺点: + V0.5.0及之前唯一支持的方式,不允许实时编辑 + +> [!warning] +> +> 如果选用了可实时编辑的方案,最好能在仓库定期备份的情况下使用,避免意外 + +### 自动保存方式 + +- onchange + - 优点: + 更好的性能 + 程序实现简单更简单,无需手动管理光标位置 + - 缺点: + 延时保存,特殊场景可能不会保存修改: 程序突然崩溃。当光标在代码块中时,直接切换到阅读模式,或关闭当前窗口/标签页 +- oninput + - 优点: + 实时保存,数据更安全 + 同为块内编辑的obsidian新版本md表格,采用的是这种方式 + - 缺点: + 性能略差? 每次修改都要重新创建代码块 + 程序需要手动管理光标位置,手动防抖。 + 需要注意输入法问题,输入候选阶段也会触发 `oninput` + +## Shiki扩展语法 + +详见: https://shiki.style/packages/transformers (可切换至中文) + +这是个简单的语法总结: + +- notaion 注释型标注 + - diff: `// [!code ++]` `// [!code --]` 差异化 + - highlight: `// [!code hl]` `// [!code highlight]` 高亮 + - word highlight: `// [!code word::]` `// [!code word:Hello:1]` 单词高亮 + - focus: `// [!code focus]` 聚焦 + - error level: `// [!code error]` `// [!code warning]` 警告/错误 + - (mul line): `// [!code highlight:3]` (多行) +- meta 元数据型标注 + - highlight: `{1,3-4}` + - word highlight `//` `/Hello/` + +示例: see [../README.md](../README.md) or [Shiki document](https://shiki.style/packages/transformers) diff --git a/eslint.config.mjs b/eslint.config.mjs index 7c0948d..f721456 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -29,7 +29,13 @@ export default tseslint.config( 'no-relative-import-paths': no_relative_import_paths, }, rules: { - '@typescript-eslint/no-explicit-any': ['warn'], + // `any` about + '@typescript-eslint/no-explicit-any': 'off', // ['warn'], + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' }], '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports', fixStyle: 'inline-type-imports' }], diff --git a/package.json b/package.json index 3f61962..8e4c4f3 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "bun run automation/build/esbuild.dev.config.ts", "build": "bun run tsc && bun run automation/build/esbuild.config.ts", + "build-min": "bun run tsc && bun run automation/build/esbuild.config.min.ts", "tsc": "tsc -noEmit -skipLibCheck", "test": "bun test", "test:log": "LOG_TESTS=true bun test", @@ -22,6 +23,8 @@ "author": "Moritz Jung", "license": "MIT", "devDependencies": { + "@codemirror/basic-setup": "^0.20.0", + "@codemirror/lang-markdown": "^6.3.2", "@codemirror/language": "^6.11.0", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.36.8", @@ -35,8 +38,10 @@ "@happy-dom/global-registrator": "^17.4.7", "@lemons_dev/parsinom": "^0.0.12", "@lezer/common": "^1.2.3", + "@shikijs/transformers": "^3.4.2", "@tsconfig/svelte": "^5.0.4", "@types/bun": "^1.2.13", + "@types/prismjs": "^1.26.5", "builtin-modules": "^5.0.0", "esbuild": "^0.25.4", "esbuild-plugin-copy-watch": "^2.3.1", @@ -53,4 +58,4 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.32.1" } -} +} \ No newline at end of file diff --git a/src/EditableCodeblock.ts b/src/EditableCodeblock.ts new file mode 100644 index 0000000..9798f93 --- /dev/null +++ b/src/EditableCodeblock.ts @@ -0,0 +1,993 @@ +/** + * General editable code blocks based on shiki/prismjs + * + * This will gradually be modified into a universal module that does not rely on obsidian + */ + +import { + type App, + debounce, + type Editor, + loadPrism, + type MarkdownPostProcessorContext, + Notice, + MarkdownRenderer, + MarkdownRenderChild, + MarkdownView, +} from 'obsidian'; +import { type Settings } from 'src/settings/Settings'; + +// import { +// // ScrollableMarkdownEditor, +// WorkspaceLeaf, MarkdownEditView, +// ViewState, livePreviewState, editorEditorField +// } from 'obsidian'; +import { EditorState } from '@codemirror/state'; +import { EditorView, type ViewUpdate } from '@codemirror/view'; +import { markdown } from "@codemirror/lang-markdown"; +import { basicSetup } from "@codemirror/basic-setup"; +import { getEmbedEditor, makeFakeController } from "src/EditableEditor" + +import { + transformerNotationDiff, + transformerNotationHighlight, + transformerNotationFocus, + transformerNotationErrorLevel, + transformerNotationWordHighlight, + + transformerMetaHighlight, + transformerMetaWordHighlight, +} from '@shikijs/transformers'; +import { bundledThemesInfo, codeToHtml } from 'shiki'; // 8.6MB +import type Prism from 'prismjs'; + +const reg_code = /^((\s|>\s|-\s|\*\s|\+\s)*)(```+|~~~+)(\S*)(\s?.*)/ +// const reg_code_noprefix = /^((\s)*)(```+|~~~+)(\S*)(\s?.*)/ + +/** + * Codeblock Info. + * Life cycle: One codeblock has one. + * Pay attention to consistency. + */ +export interface CodeblockInfo { + // from ctx.getSectionInfo(el) // [!code warning] There may be indentation + prefix: string, // `> - * + ` // [!code warning] Because of the list nest, first-line indentation is not equal to universal indentation. + flag: string, // (```+|~~~+) + language_meta: string, // allow both end space, allow blank + language_type: string, // source code, can be an alias + source: string|null, + + // from obsidian callback args // [!code warning] It might be old data in oninput/onchange method + language_old: string, // to lib, can't be an alias + source_old: string, +} + +// RAII, use: setValue -> refresh -> getValue -> reSetNull +let global_refresh_cache: null|{start:number, end:number} = null +// let global_isLiveMode_cache: boolean = true // TODO can add option, default cm or readmode // TODO add a state show: isSaved + +// Class definitions in rust style, The object is separated from the implementation +export class EditableCodeblock { + plugin: { app: App; settings: Settings }; + el: HTMLElement; + ctx: MarkdownPostProcessorContext; + editor: Editor|null; // Cache to avoid focus changes. And the focus point may not be correct when creating the code block. It can be updated again when oninput + codeblockInfo: CodeblockInfo; + + // redundancy + isReadingMode: boolean; + isMarkdownRendered: boolean; + + constructor(plugin: { app: App; settings: Settings }, language_old:string, source_old:string, el:HTMLElement, ctx:MarkdownPostProcessorContext) { + this.plugin = plugin + this.el = el + this.ctx = ctx + this.editor = this.plugin.app.workspace.activeEditor?.editor ?? null; + + this.isReadingMode = ctx.containerEl.hasClass('markdown-preview-section') || ctx.containerEl.hasClass('markdown-preview-view'); + this.isMarkdownRendered = !ctx.el.hasClass('.cm-preview-code-block') && ctx.el.hasClass('markdown-rendered') // TODO fix: can't check codeblock in Editor codeblock + + this.codeblockInfo = EditableCodeblock.createCodeBlockInfo(language_old, source_old, el, ctx) + this.codeblockInfo.source = this.codeblockInfo.source_old + } + + // Data related to codeblock + static createCodeBlockInfo(language_old:string, source_old:string, el:HTMLElement, ctx:MarkdownPostProcessorContext): CodeblockInfo { + const sectionInfo = ctx.getSectionInfo(el); + if (!sectionInfo) { + // This is possible. when rerender + const codeblockInfo:CodeblockInfo = { + prefix: '', + flag: '', // null flag + language_meta: '', + language_type: language_old, + source: null, // null flag + + language_old: language_old, + source_old: source_old, + } + return codeblockInfo + } + // sectionInfo.lineStart; // index in (```) + // sectionInfo.lineEnd; // index in (```), Let's not modify the fence part + + const lines = sectionInfo.text.split('\n') + if (lines.length < sectionInfo.lineStart + 1 || lines.length < sectionInfo.lineEnd + 1) { + // This is impossible. + // Unless obsidian makes a mistake. + new Notice("Warning: el ctx error!", 3000) + throw new Error('Warning: el ctx error!') + } + + const firstLine = lines[sectionInfo.lineStart] + const match = reg_code.exec(firstLine) + if (!match) { + // This is possible. + // When the code block is nested and the first line is not a code block + // (The smallest section of getSectionInfo is `markdown-preview-section>div`) + const codeblockInfo:CodeblockInfo = { + prefix: '', + flag: '', // null flag + language_meta: '', + language_type: language_old, + source: null, // null flag + + language_old: language_old, + source_old: source_old, + } + return codeblockInfo + } + + const codeblockInfo:CodeblockInfo = { + prefix: match[1], + flag: match[3], + language_meta: match[5], + language_type: match[4], + source: lines.slice(sectionInfo.lineStart + 1, sectionInfo.lineEnd).join('\n'), + + language_old: language_old, + source_old: source_old, + } + return codeblockInfo + } + + renderCallout(): void { + // - div + // - divCallout + // - divTitle + // - divIcon + // - divInner + // - divContent + // - ( ) b1 .markdown-rendered + // - ( ) b2 .cm-editor > .cm-scroller > div.contenteditable + // - divEditBtn + + // div + const div = document.createElement('div'); this.el.appendChild(div); div.classList.add( + 'cm-preview-code-block', 'cm-embed-block', 'markdown-rendered', 'admonition-parent', 'admonition-tip-parent', + ) + + // divCallout + const divCallout = document.createElement('div'); div.appendChild(divCallout); divCallout.classList.add( + 'callout', 'admonition', 'admonition-tip', 'admonition-plugin' + ); + divCallout.setAttribute('data-callout', this.codeblockInfo.language_type.slice(3)); divCallout.setAttribute('data-callout-fold', ''); divCallout.setAttribute('data-callout-metadata', '') + + // divTitle + const divTitle = document.createElement('div'); divCallout.appendChild(divTitle); divTitle.classList.add('callout-title', 'admonition-title'); + const divIcon = document.createElement('div'); divTitle.appendChild(divIcon); divIcon.classList.add('callout-icon', 'admonition-title-icon'); + divIcon.innerHTML = `` + const divInner = document.createElement('div'); divTitle.appendChild(divInner); divInner.classList.add('callout-title-inner', 'admonition-title-content'); + divInner.textContent = this.codeblockInfo.language_type.slice(3) + + // divContent + const divContent = document.createElement('div'); divCallout.appendChild(divContent); divContent.classList.add('callout-content', 'admonition-content'); + if (this.isReadingMode || this.isMarkdownRendered) { + void this.renderMarkdown(divContent) + } + + // divEditBtn + const divEditBtn = document.createElement('div'); div.appendChild(divEditBtn); divEditBtn.classList.add('edit-block-button') + divEditBtn.innerHTML = `` + + // #region divContent async part + if (!this.isReadingMode && !this.isMarkdownRendered) { + this.editor = this.plugin.app.workspace.activeEditor?.editor ?? null; // 这里,通常初始化和现在的activeEditor都拿不到editor,不知道为什么 + const view = this.plugin.app.workspace.getActiveViewOfType(MarkdownView) + if (view) this.editor = view.editor + + const embedEditor = (): void => { + divContent.innerHTML = '' + + const EmbedEditor: new (...args: any[]) => any = getEmbedEditor( + this.plugin.app, + (cm: EditorView) => { + this.codeblockInfo.source = cm.state.doc.toString() + void this.renderMarkdown(divContent) // if save but nochange, will not rerender. So it is needed. + + // global_isLiveMode_cache = false // TODO can add option, default cm or readmode + div.classList.remove('is-no-saved'); void this.saveContent_safe(false, true); + }, + (cm: EditorView) => { + this.codeblockInfo.source = cm.state.doc.toString() + + // global_isLiveMode_cache = true // TODO can add option, default cm or readmode + div.classList.remove('is-no-saved'); void this.saveContent_safe(false, true); + }, + (update: ViewUpdate, changed: boolean) => { + if (!changed) return + div.classList.add('is-no-saved'); + }, + ) + + if (EmbedEditor) { + // Strategy 1: use `class EmbedEditor extends MarkdownEditor` + const obView: MarkdownView|null = this.plugin.app.workspace.getActiveViewOfType(MarkdownView); + const controller = makeFakeController(this.plugin.app, obView??null, () => this.editor) + const embedEditor: Editor = new EmbedEditor(this.plugin.app, divContent, controller) + // @ts-expect-error without set, if no set, cm style invalid + embedEditor.set(this.codeblockInfo.source ?? '') + } + else { + // Strategy 4 use ob extensions, but without ob style + const cmState = EditorState.create({ + doc: this.codeblockInfo.source ?? this.codeblockInfo.source_old, + extensions: [ + basicSetup, + // keymap.of(defaultKeymap), + markdown(), + EditorView.updateListener.of(update => { + if (update.docChanged) { + this.codeblockInfo.source = update.state.doc.toString(); + div.classList.add('is-no-saved'); + } + }) + ] + }) + new EditorView({ // const cmView = + state: cmState, + parent: divContent // targetEl + }) + // async + const elCmEditor: HTMLElement|null = divContent.querySelector('div[contenteditable=true]') + if (!elCmEditor) { + console.warn('can\'t find elCmEditor') + return + } + elCmEditor.focus() + elCmEditor.addEventListener('blur', (): void => { + void this.renderMarkdown(divContent) // if save but nochange, will not rerender. So it is needed. + + div.classList.remove('is-no-saved'); void this.saveContent_safe(false, true); + }) + } + } + + // if (global_isLiveMode_cache) { + // global_isLiveMode_cache = false // TODO can add option, default cm or readmode + embedEditor() + divContent.addEventListener('dblclick', () => { embedEditor() }) + } + // #endregion + } + + /** + * param this.plugin.settings.saveMode onchange/oninput + */ + renderTextareaPre(): void { + // dom + // - div.obsidian-shiki-plugin + // - span > pre > code + // - textarea + // - div.language-edit + + // div + const div = document.createElement('div'); this.el.appendChild(div); div.classList.add('obsidian-shiki-plugin') + + // span + const span = document.createElement('span'); div.appendChild(span); + void this.renderPre(span) + + // textarea + const textarea = document.createElement('textarea'); div.appendChild(textarea); + const attributes = { + 'resize-none': '', 'autocomplete': 'off', 'autocorrect': 'off', 'autocapitalize': 'none', 'spellcheck': 'false', + }; + Object.entries(attributes).forEach(([key, val]) => { + textarea.setAttribute(key, val); + }); + textarea.value = this.codeblockInfo.source ?? this.codeblockInfo.source_old; + + // language-edit + const editEl = document.createElement('div'); div.appendChild(editEl); editEl.classList.add('language-edit'); + editEl.setAttribute('align', 'right'); + const editInput = document.createElement('input'); editEl.appendChild(editInput); + editInput.value = this.codeblockInfo.language_type + this.codeblockInfo.language_meta + + // readmode and markdown reRender not shouldn't change + if (this.isReadingMode || this.isMarkdownRendered) { + textarea.setAttribute('readonly', '') + textarea.setAttribute('display', '') + editInput.setAttribute('readonly', '') + return + } + + // #region textarea - async part - composition start/end + let isComposing = false; // is in the input method combination stage, can fix chinese input method invalid + textarea.addEventListener('compositionstart', () => { + isComposing = true + }); + + textarea.addEventListener('compositionend', () => { + isComposing = false + // updateCursorPosition(); // (option) + }); + // #endregion + + // #region textarea - async part - oninput/onchange + // refresh/save strategy1: input no save + if (this.plugin.settings.saveMode == 'onchange') { + textarea.oninput = (ev): void => { + if (isComposing) return + + this.editor = this.plugin.app.workspace.activeEditor?.editor ?? null; + + const newValue = (ev.target as HTMLTextAreaElement).value + this.codeblockInfo.source = newValue + void this.renderPre(span) + div.classList.add('is-no-saved'); + } + textarea.onchange = (ev): void => { // save must on oninput: avoid: textarea --update--> source update --update--> textarea (lose curosr position) + const newValue = (ev.target as HTMLTextAreaElement).value + this.codeblockInfo.source = newValue + div.classList.remove('is-no-saved'); void this.saveContent_safe(false, true) + } + } + // refresh/save strategy2: cache and rebuild + else { + void Promise.resolve().then(() => { + if (!global_refresh_cache) return + // this.el.appendChild(global_refresh_cache.el) + // const textarea: HTMLTextAreaElement|null = global_refresh_cache.el.querySelector('textarea') + textarea.setSelectionRange(global_refresh_cache.start, global_refresh_cache.end) + textarea.focus() + global_refresh_cache = null + // return + }) + textarea.oninput = (ev): void => { + if (isComposing) return + + this.editor = this.plugin.app.workspace.activeEditor?.editor ?? null; + + const newValue = (ev.target as HTMLTextAreaElement).value + this.codeblockInfo.source = newValue + void this.renderPre(span) + + global_refresh_cache = { + start: textarea.selectionStart, + end: textarea.selectionEnd, + } + div.classList.remove('is-no-saved'); void this.saveContent_safe(false, true) + } + } + // #endregion + + // #region language-edit - async part + if (this.plugin.settings.saveMode != 'oninput') { + // no support + } + { + editInput.oninput = (ev): void => { + if (isComposing) return + + this.editor = this.plugin.app.workspace.activeEditor?.editor ?? null; + + const newValue = (ev.target as HTMLInputElement).value + const match = /^(\S*)(\s?.*)$/.exec(newValue) + if (!match) throw new Error('This is not a regular expression matching that may fail') + this.codeblockInfo.language_type = match[1] + this.codeblockInfo.language_meta = match[2] + void this.renderPre(span) + div.classList.add('is-no-saved'); + } + editInput.onchange = (ev): void => { // save must on oninput: avoid: textarea --update--> source update --update--> textarea (lose curosr position) + const newValue = (ev.target as HTMLInputElement).value + const match = /^(\S*)(\s?.*)$/.exec(newValue) + if (!match) throw new Error('This is not a regular expression matching that may fail') + this.codeblockInfo.language_type = match[1] + this.codeblockInfo.language_meta = match[2] + div.classList.remove('is-no-saved'); void this.saveContent_safe(true, false) + } + } + // #endregion + + // #region textarea - async part - keydown + this.enableTabEmitIndent(textarea, undefined, undefined, (ev)=>{ + const selectionEnd: number = textarea.selectionEnd + const textBefore = textarea.value.substring(0, selectionEnd) + const linesBefore = textBefore.split('\n') + if (linesBefore.length !== textarea.value.split('\n').length) return + + ev.preventDefault() // safe: tested: `prevent` can still trigger `onChange + editInput.setSelectionRange(0, 0) + editInput.focus() + }) + // #endregion + + // #region language-edit - async part - keydown + this.enableTabEmitIndent(editInput, undefined, (ev)=>{ + ev.preventDefault() // safe: tested: `prevent` can still trigger `onChange + const position = textarea.value.length + textarea.setSelectionRange(position, position) + textarea.focus() + }, undefined) + // #endregion + } + + /** + * param this.plugin.settings.saveMode onchange/oninput + */ + async renderEditablePre(): Promise { + // dom + // - div.obsidian-shiki-plugin.editable-pre + // - pre + // - code.language- + + // div + const div = document.createElement('div'); this.el.appendChild(div); div.classList.add('obsidian-shiki-plugin', 'editable-pre') + + // pre, code + await this.renderPre(div) + let pre: HTMLPreElement|null = div.querySelector(':scope>pre') + let code: HTMLPreElement|null = div.querySelector(':scope>pre>code') + if (!pre || !code) { console.error('render failed. can\'t find pre/code 1'); return } + code.setAttribute('contenteditable', 'true'); code.setAttribute('spellcheck', 'false') + + // readmode and markdown reRender not shouldn't change + if (this.isReadingMode || this.isMarkdownRendered) { + code.setAttribute('readonly', '') + return + } + + // #region code - async part - composition start/end + let isComposing = false; // is in the input method combination stage, can fix chinese input method invalid + code.addEventListener('compositionstart', () => { + isComposing = true + }); + + code.addEventListener('compositionend', () => { + isComposing = false + // updateCursorPosition(); // (option) + }); + // #endregion + + // #region code - async part - oninput/onchange + // refresh/save strategy1: input no save + if (this.plugin.settings.saveMode == 'onchange') { + void Promise.resolve().then(() => { + if (!global_refresh_cache) return + if (!pre || !code) { console.error('render failed. can\'t find pre/code 11'); global_refresh_cache = null; return } + this.renderEditablePre_restoreCursorPosition(pre, global_refresh_cache.start, global_refresh_cache.end) + global_refresh_cache = null + }) + code.oninput = (ev): void => { + if (isComposing) return + if (!pre || !code) { console.error('render failed. can\'t find pre/code 12'); return } + + this.editor = this.plugin.app.workspace.activeEditor?.editor ?? null; + + const newValue = (ev.target as HTMLPreElement).innerText // .textContent more fast, but can't get new line by 'return' (\n yes, br no) + this.codeblockInfo.source = newValue + + void Promise.resolve().then(async () => { // like vue nextTick, ensure that the cursor is behind + pre = div.querySelector(':scope>pre') + code = div.querySelector(':scope>pre>code') + if (!pre || !code) { console.error('render failed. can\'t find pre/code 13'); global_refresh_cache = null; return } + + // save pos + global_refresh_cache = this.renderEditablePre_saveCursorPosition(pre) + + // pre, code + await this.renderPre(div, code) + div.classList.add('is-no-saved'); + + // restore pos + code.setAttribute('contenteditable', 'true'); code.setAttribute('spellcheck', 'false') + + if (!global_refresh_cache) return + this.renderEditablePre_restoreCursorPosition(pre, global_refresh_cache.start, global_refresh_cache.end) + global_refresh_cache = null + }) + } + // pre/code without onchange, use blur event + code.addEventListener('blur', (ev): void => { // save must on oninput: avoid: textarea --update--> source update --update--> textarea (lose curosr position) + const newValue = (ev.target as HTMLPreElement).innerText // .textContent more fast, but can't get new line by 'return' (\n yes, br no) + this.codeblockInfo.source = newValue // prism use textContent and shiki use innerHTML, Their escapes from `` are different + div.classList.remove('is-no-saved'); void this.saveContent_safe(false, true) + }) + } + // refresh/save strategy2: cache and rebuild + else { + void Promise.resolve().then(() => { + if (!global_refresh_cache) return + if (!pre || !code) { console.error('render failed. can\'t find pre/code 21'); global_refresh_cache = null; return } + this.renderEditablePre_restoreCursorPosition(pre, global_refresh_cache.start, global_refresh_cache.end) + global_refresh_cache = null + }) + code.oninput = (ev): void => { + if (isComposing) return + if (!pre || !code) { console.error('render failed. can\'t find pre/code 22'); return } + + this.editor = this.plugin.app.workspace.activeEditor?.editor ?? null; + + const newValue = (ev.target as HTMLPreElement).innerText // .textContent more fast, but can't get new line by 'return' (\n yes, br no) + this.codeblockInfo.source = newValue + void this.renderPre(div) + + + global_refresh_cache = this.renderEditablePre_saveCursorPosition(pre) + div.classList.remove('is-no-saved'); void this.saveContent_safe(false, true) + } + } + // #endregion + + // #region code - async part - keydown + this.enableTabEmitIndent(code) + // #endregion + } + + renderEditablePre_saveCursorPosition(container: Node): null|{start: number, end: number} { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return null + + const range: Range = selection.getRangeAt(0) + + // get start + const preRange: Range = document.createRange() + preRange.selectNodeContents(container) + preRange.setEnd(range.startContainer, range.startOffset) + const start = preRange.toString().length + + return { + start, + end: start + range.toString().length + } + } + + renderEditablePre_restoreCursorPosition(container: Node, start: number, end: number): void { + // get range + const range: Range = document.createRange() + let charIndex = 0 + let isFoundStart = false + let isFoundEnd = false + function traverse(node: Node): void { + if (node.nodeType === Node.TEXT_NODE) { // pre/code is Node.ELEMENT_NODE, not inconformity + const nextIndex = charIndex + (node.nodeValue?.length ?? 0) + if (!isFoundStart && start >= charIndex && start <= nextIndex) { // start + range.setStart(node, start - charIndex) + isFoundStart = true + } + if (isFoundStart && !isFoundEnd && end >= charIndex && end <= nextIndex) { // end + range.setEnd(node, end - charIndex) + isFoundEnd = true + } + charIndex = nextIndex + } + else { + for (const child of node.childNodes) { + traverse(child) + if (isFoundEnd) break + } + } + } + traverse(container) + + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + } + + // There will be a strong sense of lag, and the experience is not good + /** + * @deprecated There will be a strong sense of lag, and the experience is not good. + * you should use `renderPre` version + */ + renderPre_debounced = debounce(async (targetEl:HTMLElement): Promise => { + void this.renderPre(targetEl) + console.log('debug renderPre debounced') + }, 200) + + /** + * Render code to targetEl + * + * param this.plugin.settings.renderEngine shiki/prism + * @param targetEl in which element should the result be rendered + * - targetEl (usually a div) + * - pre + * - code + * @param code (option) code element, can reduce the refresh rate, avoid code blur event + */ + async renderPre(targetEl:HTMLElement, code?:HTMLElement): Promise { + // source correct. + // When the last line of the source is blank (with no Spaces either), + // prismjs and shiki will both ignore the line, + // this causes `textarea` and `pre` to fail to align. + let source: string = this.codeblockInfo.source ?? this.codeblockInfo.source_old + if (source.endsWith('\n')) source += '\n' + + // pre html string - shiki, insert `
...
`
+		if (this.plugin.settings.renderEngine == 'shiki') {
+			// check theme, TODO: use more theme
+			let theme = ''
+			for (const item of bundledThemesInfo) {
+				if (item.id == this.plugin.settings.theme) { theme = this.plugin.settings.theme; break }
+			}
+			if (theme === '') {
+				theme = 'andromeeda'
+				// console.warn(`no support theme '${this.plugin.settings.theme}' temp in this render mode`) // [!code error] TODO fix
+			}
+
+			const preStr:string = await codeToHtml(source, {
+				lang: this.codeblockInfo.language_old,
+				theme: theme,
+				meta: { __raw: this.codeblockInfo.language_meta },
+				// https://shiki.style/packages/transformers
+				transformers: [
+					transformerNotationDiff({ matchAlgorithm: 'v3' }),
+					transformerNotationHighlight(),
+					transformerNotationFocus(),
+					transformerNotationErrorLevel(),
+					transformerNotationWordHighlight(),
+
+					transformerMetaHighlight(),
+					transformerMetaWordHighlight(),
+				],
+			})
+
+			if (!code) {
+				targetEl.innerHTML = preStr // prism use textContent and shiki use innerHTML, Their escapes from `` are different
+			}
+			else {
+				const parser = new DOMParser();
+  				const doc = parser.parseFromString(preStr, 'text/html');
+				const codeElement = doc.querySelector('pre>code')
+				if (!codeElement) { console.error('shiki return preStr without code tag', doc); return }
+				code.innerHTML = codeElement.innerHTML
+			}
+		}
+		// pre html string - prism, insert `
...
`
+		else {
+			const prism = await loadPrism() as typeof Prism;
+			if (!prism) {
+				new Notice('warning: withou Prism')
+				throw new Error('warning: withou Prism')
+			}
+
+			if (!code) {
+				targetEl.innerHTML = ''
+				const pre = document.createElement('pre'); targetEl.appendChild(pre);
+				code = document.createElement('code'); pre.appendChild(code); code.classList.add('language-'+this.codeblockInfo.language_type);
+			}
+
+			code.textContent = source; // prism use textContent and shiki use innerHTML, Their escapes from `` are different
+			prism.highlightElement(code)
+		}
+	}
+
+	renderMarkdown(targetEl: HTMLElement): Promise {
+		targetEl.innerHTML = ''
+		const divRender = document.createElement('div'); targetEl.appendChild(divRender); divRender.classList.add('markdown-rendered');
+		const mdrc: MarkdownRenderChild = new MarkdownRenderChild(divRender);
+		return MarkdownRenderer.render(this.plugin.app, this.codeblockInfo.source ?? this.codeblockInfo.source_old, divRender, this.plugin.app.workspace.getActiveViewOfType(MarkdownView)?.file?.path??"", mdrc)
+	}
+
+	// TODO: fix: after edit, can't up/down to root editor
+	// el: HTMLTextAreaElement|HTMLInputElement|HTMLPreElement
+	enableTabEmitIndent(el: HTMLElement, cb_tab?: (ev: KeyboardEvent)=>void, cb_up?: (ev: KeyboardEvent)=>void, cb_down?: (ev: KeyboardEvent)=>void): void {
+		if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el.isContentEditable)) return
+
+		// #region textarea - async part - keydown
+		el.addEventListener('keydown', (ev: KeyboardEvent) => { // `tab` key、`arrow` key	
+			if (ev.key == 'Tab') {
+				if (cb_tab) { cb_tab(ev); return }
+				ev.preventDefault()
+
+				// get indent
+				const configUseTab = this.plugin.app.vault.getConfig('useTab')
+				const configTabSize = this.plugin.app.vault.getConfig('tabSize')
+				const indent_space = ' '.repeat(configTabSize)
+				let indent = configUseTab ? '\t' : indent_space
+				
+				if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
+					const value = el.value
+					const selectionStart: number = el.selectionStart ?? 0
+					const selectionEnd: number = el.selectionEnd ?? 0
+
+					// auto indent (otpion)
+					{
+						const lineStart: number = value.lastIndexOf('\n', selectionStart - 1) + 1
+						const lineEnd: number = value.indexOf('\n', selectionStart)
+						const lineCurrent: string = value.substring(lineStart, lineEnd === -1 ? value.length : lineEnd)
+						// TODO enhamce: determine whether to insert the tab directly or the entire line of tabs based on the cursor
+						if (lineCurrent.startsWith('\t')) indent = '\t'
+						else if (lineCurrent.startsWith(' ')) indent = indent_space
+					}
+					
+					// change
+					// new value: cursorBefore + tab + cusrorAfter
+					el.value = el.value.substring(0, selectionStart) + indent + el.value.substring(selectionEnd)
+					// new cursor pos
+					el.selectionStart = el.selectionEnd = selectionStart + indent.length;
+					el.dispatchEvent(new InputEvent('input', {
+						inputType: 'insertText',
+						data: indent,
+						bubbles: true,
+						cancelable: true
+					}))
+					return
+				}
+				else { // pre/code
+					const selection = window.getSelection();
+					if (!selection || selection.rangeCount === 0) return;
+					
+					// auto indent (otpion)
+					let range
+					{
+						range = selection.getRangeAt(0)
+						const container = range.startContainer
+						const lineText = container.textContent ?? ''
+						const lineStart = lineText.lastIndexOf('\n', range.startOffset - 1) + 1
+						const lineCurrent = lineText.slice(lineStart, range.startOffset)
+						if (lineCurrent.startsWith('\t')) indent = '\t'
+						else if (lineCurrent.startsWith(' ')) indent = indent_space
+					}
+					
+					// change
+					// new value
+					const textNode = document.createTextNode(indent)
+					range.deleteContents()
+					range.insertNode(textNode)
+					// new cursor pos
+					const newRange = document.createRange();
+					newRange.setStartAfter(textNode);
+					newRange.collapse(true);
+					selection.removeAllRanges();
+					selection.addRange(newRange);
+					el.dispatchEvent(new InputEvent('input', {
+						inputType: 'insertText',
+						data: indent,
+						bubbles: true,
+						cancelable: true
+					}));
+					return
+				}
+				return
+			}
+			else if (ev.key == 'ArrowDown') {
+				if (cb_down) { cb_down(ev); return }
+				if (!this.editor) return
+
+				// check is the last line
+				if (el instanceof HTMLInputElement) {
+					// true
+				} else if (el instanceof HTMLTextAreaElement) {
+					const selectionEnd: number = el.selectionEnd
+					const textBefore = el.value.substring(0, selectionEnd)
+					const linesBefore = textBefore.split('\n')
+					if (linesBefore.length !== el.value.split('\n').length) return
+				} else {
+					// TODO
+					return
+				}
+				
+				const sectionInfo = this.ctx.getSectionInfo(this.el);
+				if (!sectionInfo) return
+
+				ev.preventDefault() // safe: tested: `prevent` can still trigger `onChange`
+				const toLine = sectionInfo.lineEnd + 1
+				if (toLine > this.editor.lineCount() - 1) { // when codeblock on the last line
+					// strategy1: only move to end
+					// toLine--
+
+					// strategy2: insert a blank line
+					const lastLineIndex = this.editor.lineCount() - 1
+					const lastLineContent = this.editor.getLine(lastLineIndex)
+					this.editor.replaceRange("\n", { line: lastLineIndex, ch: lastLineContent.length })
+				}
+				this.editor.setCursor(toLine, 0)
+				this.editor.focus()
+				return
+			}
+			else if (ev.key == 'ArrowUp') {
+				if (cb_up) { cb_up(ev); return }
+				if (!this.editor) return
+
+				// check is the first line
+				if (el instanceof HTMLInputElement) {
+					// true
+				} else if (el instanceof HTMLTextAreaElement) {
+					const selectionStart: number = el.selectionStart
+					const textBefore = el.value.substring(0, selectionStart)
+					const linesBefore = textBefore.split('\n')
+					if (linesBefore.length !== 1) return
+				} else {
+					// TODO
+					return
+				}
+
+				const sectionInfo = this.ctx.getSectionInfo(this.el);
+				if (!sectionInfo) return
+
+				ev.preventDefault() // safe: tested: `prevent` can still trigger `onChange`
+				let toLine = sectionInfo.lineStart - 1
+				if (toLine < 0) { // when codeblock on the frist line
+					// strategy1: only move to start
+					// toLine = 0
+
+					// strategy2: insert a blank line
+					toLine = 0
+					this.editor.replaceRange("\n", { line: 0, ch: 0 })
+				}
+				this.editor.setCursor(toLine, 0)
+				this.editor.focus()
+				return
+			}
+			/*else if (ev.key == 'ArrowRight') {
+				if (cb_down) { cb_down(ev); return }
+				if (!this.editor) return
+
+				// check is the last char
+				if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
+					const selectionEnd: number|null = el.selectionEnd
+					if (selectionEnd === null || selectionEnd != el.value.length) return
+				} else {
+					// TODO
+					return
+				}
+				
+				const sectionInfo = this.ctx.getSectionInfo(this.el);
+				if (!sectionInfo) return
+
+				ev.preventDefault() // safe: tested: `prevent` can still trigger `onChange`
+				const toLine = sectionInfo.lineEnd + 1
+				if (toLine > this.editor.lineCount() - 1) { // when codeblock on the last line
+					// strategy1: only move to end
+					// toLine--
+
+					// strategy2: insert a blank line
+					const lastLineIndex = this.editor.lineCount() - 1
+					const lastLineContent = this.editor.getLine(lastLineIndex)
+					this.editor.replaceRange("\n", { line: lastLineIndex, ch: lastLineContent.length })
+				}
+				this.editor.setCursor(toLine, 0)
+				this.editor.focus()
+				return
+			}
+			else if (ev.key == 'ArrowLeft') {
+				if (cb_up) { cb_up(ev); return }
+				if (!this.editor) return
+
+				// check is the first char
+				if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
+					const selectionStart: number|null = el.selectionStart
+					if (selectionStart === null || selectionStart != 0) return
+				} else {
+					// TODO
+					return
+				}
+
+				const sectionInfo = this.ctx.getSectionInfo(this.el);
+				if (!sectionInfo) return
+
+				ev.preventDefault() // safe: tested: `prevent` can still trigger `onChange`
+				let toLine = sectionInfo.lineStart - 1
+				if (toLine < 0) { // when codeblock on the frist line
+					// strategy1: only move to start
+					// toLine = 0
+
+					// strategy2: insert a blank line
+					toLine = 0
+					this.editor.replaceRange("\n", { line: 0, ch: 0 })
+				}
+				this.editor.setCursor(toLine, 0)
+				this.editor.focus()
+				return
+			}*/
+		})
+		// #endregion
+	}
+
+	async saveContent_safe(isUpdateLanguage: boolean = true, isUpdateSource: boolean = true): Promise {
+		// [!code warn:3] The exception caused by the transaction cannot be caught.
+		// If it fails here, there will be an error print
+		// ~~so, use double save. Ensure both speed and safety at the same time.~~
+		// (When adding or deleting at the end, there will be bugs in double save)
+		// try {
+		// } catch {
+		// }
+		if (this.plugin.settings.saveMode == 'oninput') {
+			void this.saveContent(isUpdateLanguage, isUpdateSource)
+		}
+		else if (this.plugin.settings.saveMode == 'onchange') {
+			void this.saveContent_debounced(isUpdateLanguage, isUpdateSource)
+		}
+	}
+
+	/**
+	 * @deprecated You should use `saveContent_safe` version
+	 */
+	saveContent_debounced = debounce(async (isUpdateLanguage: boolean = true, isUpdateSource: boolean = true) => {
+		void this.saveContent(isUpdateLanguage, isUpdateSource)
+	}, 200)
+
+	/**
+	 * Save textarea text content to codeBlock markdown source
+	 * 
+	 * @deprecated can't save when cursor in codeblock and use short-key switch to source mode.
+	 * You should use `saveContent_safe` version
+	 * 
+	 * Data security (Importance)
+	 * - Make sure `Ctrl+z` is normal: use transaction
+	 * - Make sure check error: try-catch
+	 * - Make sure to remind users of errors: use Notice
+	 * - Avoid overwriting the original data with incorrect data, this is unacceptable
+	 * 
+	 * Refresh strategy1 (unable): real-time save, debounce
+	 * - We need to ensure that the textarea element is not recreated when updating
+	 *   the content of the code block. It should be reused to avoid changes in the cursor position.
+	 * - Reduce the update frequency and the number of transactions.
+	 *   Multiple calls within a certain period of time will only become one. (debounce)
+	 * 
+	 * Refresh strategy2 (enable): onchange emit
+	 * - It is better implemented under the obsidian architecture.
+	 *   Strategy1 requires additional processing: cache el
+	 * - ~~Disadvantage: Can't use `ctrl+z` well in the code block.~~
+	 *   textarea can be `ctrl+z` normally
+	 * - Afraid if the program crashes, the frequency of save is low
+	 * 
+	 * Other / Universal
+	 * - This should be a universal module. It has nothing to do with the logic of the plugin.
+	 * - Indent process
+	 * 
+	 * @param isUpdateLanguage reduce modifications and minimize mistakes, can be used to increase stability
+	 * @param isUpdateSource   reduce modifications and minimize mistakes, can be used to increase stability
+	 */
+	async saveContent(isUpdateLanguage: boolean = true, isUpdateSource: boolean = true): Promise {
+		// range
+		const sectionInfo = this.ctx.getSectionInfo(this.el);
+		if (!sectionInfo) {
+			new Notice("Warning: without el section!", 3000)
+			return;
+		}
+		// sectionInfo.lineStart; // index in (```)
+		// sectionInfo.lineEnd;   // index in (```), Let's not modify the fence part
+
+		// editor
+		if (!this.editor) {
+			new Notice("Warning: without editor!", 3000)
+			return;
+		}
+
+		// change - language
+		if (isUpdateLanguage) {
+			this.editor.transaction({
+				changes: [{
+					from: {line: sectionInfo.lineStart, ch: 0},
+					to: {line: sectionInfo.lineStart+1, ch: 0},
+					text: this.codeblockInfo.flag + this.codeblockInfo.language_type + this.codeblockInfo.language_meta + '\n'
+				}],
+			});
+		}
+
+		// change - source
+		if (isUpdateSource) {
+			this.editor.transaction({
+				changes: [{
+					from: {line: sectionInfo.lineStart+1, ch: 0},
+					to: {line: sectionInfo.lineEnd, ch: 0},
+					text: (this.codeblockInfo.source ?? this.codeblockInfo.source_old) + '\n'
+				}],
+			});
+		}
+	}
+}
diff --git a/src/EditableEditor.ts b/src/EditableEditor.ts
new file mode 100644
index 0000000..dcd163b
--- /dev/null
+++ b/src/EditableEditor.ts
@@ -0,0 +1,234 @@
+/*
+ * thanks ~~https://github.com/Fevol/obsidian-criticmarkup/blob/6f2e8ed3fcf3a548875f7bd2fe09b9df2870e4fd/src/ui/embeddable-editor.ts~~
+ *   https://github.com/Fevol/obsidian-criticmarkup/blob/6f2e8ed3fcf3a548875f7bd2fe09b9df2870e4fd/src/ui/embeddable-editor.ts
+ * thanks https://github.com/mgmeyers/obsidian-kanban/blob/main/src/components/Editor/MarkdownEditor.tsx#L134
+ *   view: KanbanView
+ *   plugin: KanbanPlugin https://github.com/mgmeyers/obsidian-kanban/blob/main/src/KanbanView.tsx
+ *   MarkdownEditor = Object.getPrototypeOf(Object.getPrototypeOf(md.editMode)).constructor; https://github.com/mgmeyers/obsidian-kanban/blob/main/src/main.ts#L41
+ */
+
+import { insertBlankLine } from '@codemirror/commands'
+import { Prec, type Extension } from "@codemirror/state"
+import { EditorView, keymap, ViewUpdate } from "@codemirror/view"
+import { type App, type Editor, type TFile, type MarkdownView } from "obsidian"
+
+function getEditorClass(app: App): any {
+	// @ts-expect-error without embedRegistry
+	const md: any = app.embedRegistry.embedByExtension.md(
+		{ app: app, containerEl: createDiv(), state: {} },
+		null,
+		''
+	)
+
+	md.load()
+	md.editable = true
+	md.showEditor()
+
+	const MarkdownEditor: any = Object.getPrototypeOf(Object.getPrototypeOf(md.editMode)).constructor;
+
+	md.unload()
+
+	return MarkdownEditor
+}
+
+// 伪造 controller 对象 (构造错误不影响编辑功能,但影响保存功能)
+// 对应 ctx.containerEl div.cm-scroller
+export function makeFakeController(app: App, view: MarkdownView|null, getEditor: () => Editor|null): Record {
+	return {
+		app,
+		showSearch: (): void => { },
+		toggleMode: (): void => { },
+		onMarkdownScroll: (): void => { },
+		getMode: () => "source",
+		scroll: 0,
+		editMode: null,
+		get editor(): Editor | null { return getEditor(); },
+		get file(): TFile | null | undefined { return view?.file; },
+		get path(): string { return view?.file?.path ?? ""; }
+	}
+}
+
+// let extensions: any = null // global
+
+/**
+ * event:
+ * - 'Enter'/'Shift Enter': newLine
+ * - 'Esc': (emitFinish) save and switch real-live-mode/read-mode
+ * - 'blur': (emitSave) save but no switch
+ * - 'update': no work
+ */
+export function getEmbedEditor(
+	app: App,
+	emitFinish: (cm: EditorView) => void,
+	emitSave: (cm: EditorView) => void,
+	onChange: (cupdate: ViewUpdate, changed: boolean) => void,
+): any {
+	// if (extensions !== null) return extensions
+
+	const MarkdownEditor = getEditorClass(app)
+
+	class EmbedEditor extends MarkdownEditor {
+		buildLocalExtensions(): Extension[] {
+			// obsidian自带扩展 (无法兼容插件扩展的行为)
+			const extensions = super.buildLocalExtensions();
+
+			// 管理和同步看板的状态
+			// extensions.push(stateManagerField.init(() => stateManager));
+
+			// 日期插件
+			// extensions.push(datePlugins);
+
+			// 为编辑器添加 focus 和 blur 事件的监听器
+			extensions.push(
+				Prec.highest(
+					EditorView.domEventHandlers({
+						// focus: (event: FocusEvent, view: EditorView) => {
+						// 	view.activeEditor = this.owner;
+						// 	if (Platform.isMobile) {
+						// 		view.contentEl.addClass('is-mobile-editing');
+						// 	}
+			
+						// 	evt.win.setTimeout(() => {
+						// 		this.app.workspace.activeEditor = this.owner;
+						// 		if (Platform.isMobile) {
+						// 			app.mobileToolbar.update();
+						// 		}
+						// 	});
+						// 	return true;
+						// },
+						blur: (event: FocusEvent, view: EditorView) => {
+							emitSave(view)
+							return true;
+						},
+					})
+				)
+			)
+
+			// 如果传入了 placeholder,则为编辑器设置输入占位符提示文字
+			// if (placeholder) extensions.push(placeholderExt(placeholder));
+
+			// 添加 paste 事件监听,如果传入了 onPaste,则处理粘贴事件,例如自定义内容粘贴行为
+			// if (onPaste) {
+			// 	extensions.push(
+			// 		Prec.high(
+			// 			EditorView.domEventHandlers({
+			// 				paste: onPaste,
+			// 			})
+			// 		)
+			// 	);
+			// }
+
+			// 监听按键 (Esc/Enter退出编辑,Mod(Shift)+Enter才是换行)
+			extensions.push(
+				Prec.highest(
+					keymap.of([
+						{
+							key: 'Enter',
+							run: (cm: EditorView): boolean => {
+								// 根据 Obsidian 的智能缩进配置,决定换行方式
+								if (this.app.vault.getConfig('smartIndentList')) {
+									this.editor.newlineAndIndentContinueMarkdownList()
+								} else {
+									insertBlankLine(cm as any);
+								}
+								return true
+							},
+							shift: (): boolean => { return false },
+							preventDefault: true,
+						},
+						{
+							key: 'Mod-Enter',
+							run: (cm: EditorView): boolean => {
+								// 根据 Obsidian 的智能缩进配置,决定换行方式
+								if (this.app.vault.getConfig('smartIndentList')) {
+									this.editor.newlineAndIndentContinueMarkdownList()
+								} else {
+									insertBlankLine(cm as any);
+								}
+								return true
+							},
+							shift: (): boolean => { return true },
+							preventDefault: true,
+						},
+						{
+							key: 'Escape',
+							run: (cm: EditorView): boolean => {
+								emitFinish(cm)
+								return false
+							},
+							preventDefault: true,
+						},
+					])
+				)
+			)
+
+			return extensions;
+		}
+
+		onUpdate(update: ViewUpdate, changed: boolean): void {
+			super.onUpdate(update, changed)
+				onChange(update, changed)
+		}
+	}
+
+	return EmbedEditor
+}
+
+// old strategy backup
+/*else if (this.editor) {
+	// // @ts-expect-error Editor without cm
+	const obCmView: EditorView = this.editor.cm
+	const obCmState: EditorState = obCmView.state
+	
+	// Strategy 2:直接clone state,只改doc. bug: 无法加入修改检测
+	const cmState = obCmState.update({
+		changes: { from: 0, to: obCmState.doc.length, insert: this.codeblockInfo.source ?? this.codeblockInfo.source_old },
+	}).state
+	new EditorView({ // const cmView =
+		state: cmState,
+		parent: divContent // targetEl
+	})
+
+	// Strategy 3:只取extensions,生成新state. bug: ~~很难拿到全部的extension,拿到的那个基本没用~~ 有extension也似乎不起作用
+	const obView: MarkdownView|null = this.plugin.app.workspace.getActiveViewOfType(MarkdownView);
+	const controller = makeFakeController(this.plugin.app, obView??null, () => this.editor)
+	const containerEl = document.createElement("div")
+	// // @ts-expect-error
+	const embedEditor: EmbedEditor = new EmbedEditor(app, containerEl, controller)
+	const obExtensions: any = embedEditor.buildLocalExtensions()
+	const cmState = EditorState.create({
+		doc: this.codeblockInfo.source ?? this.codeblockInfo.source_old,
+		extensions: [
+			// basicSetup,
+			// markdown(),
+			...obExtensions,
+			// EditorView.updateListener.of(update => {
+			// 	if (update.docChanged) {
+			// 		this.codeblockInfo.source = update.state.doc.toString();
+			// 	}
+			// })
+		]
+	})
+	new EditorView({ // const cmView =
+		state: cmState,
+		parent: divContent // targetEl
+	})
+}
+else {
+	Strategy 5 - HyperMD, but need hyperMD and codemirror same orgin
+	const divTextarea = document.createElement('textarea'); divContent.appendChild(divTextarea);
+	divTextarea.textContent = this.codeblockInfo.source ?? this.codeblockInfo.source_old
+	const editor = HyperMD.fromTextArea(divTextarea, {
+		mode: 'text/x-hypermd',
+		lineNumbers: false,
+	})
+
+	Strategy 6 - MarkdownEditView, but it is difficult to create within the specified div.
+	const leaf = this.plugin.app.workspace.getLeaf(true);
+	const mdView = new MarkdownView(leaf)
+	const mdEditView = new MarkdownEditView(mdView)
+
+	Strategy 7 - innerText, but without render
+	divContent.innerText = this.codeblockInfo.source ?? this.codeblockInfo.source_old
+}
+*/
diff --git a/src/Highlighter.ts b/src/Highlighter.ts
index f6c1716..c845bac 100644
--- a/src/Highlighter.ts
+++ b/src/Highlighter.ts
@@ -179,6 +179,7 @@ export class CodeHighlighter {
 		this.shiki = await createHighlighter({
 			themes: [await this.themeMapper.getTheme()],
 			langs: this.customLanguages,
+
 		});
 	}
 
diff --git a/src/PrismPlugin.ts b/src/PrismPlugin.ts
index 1bdfae9..b25954b 100644
--- a/src/PrismPlugin.ts
+++ b/src/PrismPlugin.ts
@@ -1,5 +1,3 @@
-/* eslint-disable */
-
 /*
  * Taken from https://github.com/PrismJS/prism/blob/master/plugins/filter-highlight-all/prism-filter-highlight-all.js
  */
diff --git a/src/main.min.ts b/src/main.min.ts
new file mode 100644
index 0000000..3ea1159
--- /dev/null
+++ b/src/main.min.ts
@@ -0,0 +1,188 @@
+import { Plugin, type MarkdownPostProcessor } from 'obsidian';
+// import { CodeBlock } from 'src/CodeBlock';
+// import { createCm6Plugin } from 'src/codemirror/Cm6_ViewPlugin';
+import { DEFAULT_SETTINGS, type Settings } from 'src/settings/Settings';
+// import { ShikiSettingsTab } from 'src/settings/SettingsTab';
+// import { filterHighlightAllPlugin } from 'src/PrismPlugin';
+// import { CodeHighlighter } from 'src/Highlighter';
+import { EditableCodeblock } from 'src/EditableCodeblock'
+
+declare module 'obsidian' {
+	interface MarkdownPostProcessorContext {
+		containerEl: HTMLElement,
+		el: HTMLElement
+	}
+	interface Vault {
+		getConfig(arg: 'useTab'): boolean
+		getConfig(arg: 'tabSize'): number
+	}
+}
+
+export const SHIKI_INLINE_REGEX = /^\{([^\s]+)\} (.*)/i; // format: `{lang} code`
+
+export default class ShikiPlugin extends Plugin {
+	// highlighter!: CodeHighlighter;
+	// activeCodeBlocks!: Map;
+	settings!: Settings;
+	loadedSettings!: Settings;
+	// updateCm6Plugin!: () => Promise;
+
+	codeBlockProcessors: MarkdownPostProcessor[] = [];
+
+	async onload(): Promise {
+		await this.loadSettings();
+		this.loadedSettings = structuredClone(this.settings);
+		// this.addSettingTab(new ShikiSettingsTab(this));
+
+		// this.highlighter = new CodeHighlighter(this);
+		// await this.highlighter.load();
+
+		// this.activeCodeBlocks = new Map();
+
+		// this.registerInlineCodeProcessor();
+		this.registerCodeBlockProcessors();
+
+		// this.registerEditorExtension([createCm6Plugin(this)]);
+
+		// this is a workaround for the fact that obsidian does not rerender the code block
+		// when the start line with the language changes, and we need that for the EC meta string
+		// this.registerEvent(
+		// 	this.app.vault.on('modify', async file => {
+		// 		// sleep 0 so that the code block context is updated before we rerender
+		// 		await sleep(100);
+
+		// 		if (file instanceof TFile) {
+		// 			if (this.activeCodeBlocks.has(file.path)) {
+		// 				for (const codeBlock of this.activeCodeBlocks.get(file.path)!) {
+		// 					void codeBlock.rerenderOnNoteChange();
+		// 				}
+		// 			}
+		// 		}
+		// 	}),
+		// );
+
+		// await this.registerPrismPlugin();
+	}
+
+	// async reloadHighlighter(): Promise {
+	// 	await this.highlighter.unload();
+
+	// 	this.loadedSettings = structuredClone(this.settings);
+
+	// 	await this.highlighter.load();
+
+	// 	for (const [_, codeBlocks] of this.activeCodeBlocks) {
+	// 		for (const codeBlock of codeBlocks) {
+	// 			await codeBlock.forceRerender();
+	// 		}
+	// 	}
+
+	// 	await this.updateCm6Plugin();
+	// }
+
+	// async registerPrismPlugin(): Promise {
+	// 	/* eslint-disable */
+
+	// 	await loadPrism();
+
+	// 	const prism = await loadPrism();
+	// 	// filterHighlightAllPlugin(prism);
+	// 	prism.plugins.filterHighlightAll.reject.addSelector('div.expressive-code pre code');
+	// }
+
+	registerCodeBlockProcessors(): void {
+		// const languages = this.highlighter.obsidianSafeLanguageNames();
+		const languages = ['js', 'ts', 'rust', 'c', 'cpp', 'java', 'shell', 'bash'] // [!code ++] TODO
+
+		for (const language of languages) {
+			try {
+				this.registerMarkdownCodeBlockProcessor(
+					language,
+					async (source, el, ctx) => {
+						// check env
+						const isReadingMode: boolean = ctx.containerEl.hasClass('markdown-preview-section') || ctx.containerEl.hasClass('markdown-preview-view');
+						// this seems to indicate whether we are in the pdf export mode
+						// sadly there is no section info in this mode
+						// thus we can't check if the codeblock is at the start of the note and thus frontmatter
+						// const isPdfExport = ctx.displayMode === true;
+						// 
+						// this is so that we leave the hidden frontmatter code block in reading mode alone
+						if (language === 'yaml' && isReadingMode && ctx.frontmatter) {
+							const sectionInfo = ctx.getSectionInfo(el);
+
+							if (sectionInfo && sectionInfo.lineStart === 0) {
+								return;
+							}
+						}
+						
+						// able edit live
+						// disadvantage: First screen CLS (Page jitter)
+						{
+							const editableCodeblock = new EditableCodeblock(this, language, source, el, ctx)
+							editableCodeblock.renderTextareaPre()
+						}
+					},
+					1000,
+				);
+			} catch (e) {
+				console.warn(`Failed to register code block processor for ${language}.`, e);
+			}
+		}
+	}	
+
+	// registerInlineCodeProcessor(): void {
+	// 	this.registerMarkdownPostProcessor(async (el, ctx) => {
+	// 		const inlineCodes = el.findAll(':not(pre) > code');
+	// 		for (let codeElm of inlineCodes) {
+	// 			let match = codeElm.textContent?.match(SHIKI_INLINE_REGEX); // format: `{lang} code`
+	// 			if (match) {
+	// 				const highlight = await this.highlighter.getHighlightTokens(match[2], match[1]);
+	// 				const tokens = highlight?.tokens.flat(1);
+	// 				if (!tokens?.length) {
+	// 					continue;
+	// 				}
+
+	// 				codeElm.empty();
+	// 				codeElm.addClass('shiki-inline');
+
+	// 				for (let token of tokens) {
+	// 					this.highlighter.tokenToSpan(token, codeElm);
+	// 				}
+	// 			}
+	// 		}
+	// 	});
+	// }
+
+	onunload(): void {
+		// this.highlighter.unload();
+	}
+
+	// addActiveCodeBlock(codeBlock: CodeBlock): void {
+	// 	const filePath = codeBlock.ctx.sourcePath;
+
+	// 	if (!this.activeCodeBlocks.has(filePath)) {
+	// 		this.activeCodeBlocks.set(filePath, [codeBlock]);
+	// 	} else {
+	// 		this.activeCodeBlocks.get(filePath)!.push(codeBlock);
+	// 	}
+	// }
+
+	// removeActiveCodeBlock(codeBlock: CodeBlock): void {
+	// 	const filePath = codeBlock.ctx.sourcePath;
+
+	// 	if (this.activeCodeBlocks.has(filePath)) {
+	// 		const index = this.activeCodeBlocks.get(filePath)!.indexOf(codeBlock);
+	// 		if (index !== -1) {
+	// 			this.activeCodeBlocks.get(filePath)!.splice(index, 1);
+	// 		}
+	// 	}
+	// }
+
+	async loadSettings(): Promise {
+		this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()) as Settings;
+	}
+
+	async saveSettings(): Promise {
+		await this.saveData(this.settings);
+	}
+}
diff --git a/src/main.ts b/src/main.ts
index 1c2a661..1b73c34 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -5,6 +5,18 @@ import { DEFAULT_SETTINGS, type Settings } from 'src/settings/Settings';
 import { ShikiSettingsTab } from 'src/settings/SettingsTab';
 import { filterHighlightAllPlugin } from 'src/PrismPlugin';
 import { CodeHighlighter } from 'src/Highlighter';
+import { EditableCodeblock } from 'src/EditableCodeblock'
+
+declare module 'obsidian' {
+	interface MarkdownPostProcessorContext {
+		containerEl: HTMLElement,
+		el: HTMLElement
+	}
+	interface Vault {
+		getConfig(arg: 'useTab'): boolean
+		getConfig(arg: 'tabSize'): number
+	}
+}
 
 export const SHIKI_INLINE_REGEX = /^\{([^\s]+)\} (.*)/i; // format: `{lang} code`
 
@@ -78,21 +90,25 @@ export default class ShikiPlugin extends Plugin {
 		prism.plugins.filterHighlightAll.reject.addSelector('div.expressive-code pre code');
 	}
 
+	/**
+	 * param this.settings.renderMode 'textarea'/'pre'/'editablePre'/'codemirror'
+	 */
 	registerCodeBlockProcessors(): void {
 		const languages = this.highlighter.obsidianSafeLanguageNames();
+		languages.push('sk-tip', 'sk-note', 'sk-info', 'sk-warning', 'sk-error')
 
 		for (const language of languages) {
 			try {
 				this.registerMarkdownCodeBlockProcessor(
 					language,
 					async (source, el, ctx) => {
-						// @ts-expect-error
-						const isReadingMode = ctx.containerEl.hasClass('markdown-preview-section') || ctx.containerEl.hasClass('markdown-preview-view');
+						// check env
+						const isReadingMode: boolean = ctx.containerEl.hasClass('markdown-preview-section') || ctx.containerEl.hasClass('markdown-preview-view');
 						// this seems to indicate whether we are in the pdf export mode
 						// sadly there is no section info in this mode
 						// thus we can't check if the codeblock is at the start of the note and thus frontmatter
 						// const isPdfExport = ctx.displayMode === true;
-
+						// 
 						// this is so that we leave the hidden frontmatter code block in reading mode alone
 						if (language === 'yaml' && isReadingMode && ctx.frontmatter) {
 							const sectionInfo = ctx.getSectionInfo(el);
@@ -101,10 +117,33 @@ export default class ShikiPlugin extends Plugin {
 								return;
 							}
 						}
-
-						const codeBlock = new CodeBlock(this, el, source, language, ctx);
-
-						ctx.addChild(codeBlock);
+						
+						// able edit live
+						if (language.startsWith('sk-')) { // editable callout
+							const editableCodeblock = new EditableCodeblock(this, language, source, el, ctx)
+							editableCodeblock.renderCallout()
+							return
+						}
+						else if (this.settings.renderMode === 'textarea') { // disadvantage: First screen CLS (Page jitter)
+							const editableCodeblock = new EditableCodeblock(this, language, source, el, ctx)
+							editableCodeblock.renderTextareaPre()
+							return
+						}
+						else if (this.settings.renderMode === 'pre') {
+							const editableCodeblock = new EditableCodeblock(this, language, source, el, ctx)
+							void editableCodeblock.renderPre(el)
+							return
+						}
+						else if (this.settings.renderMode === 'editablePre') {
+							const editableCodeblock = new EditableCodeblock(this, language, source, el, ctx)
+							void editableCodeblock.renderEditablePre()
+							return
+						}
+						else {
+							const codeBlock = new CodeBlock(this, el, source, language, ctx);
+							ctx.addChild(codeBlock)
+							return
+						}
 					},
 					1000,
 				);
diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts
index a4e3e13..3d691cc 100644
--- a/src/settings/Settings.ts
+++ b/src/settings/Settings.ts
@@ -3,6 +3,9 @@ export interface Settings {
 	customThemeFolder: string;
 	customLanguageFolder: string;
 	theme: string;
+	renderMode: 'textarea'|'pre'|'editablePre'|'codemirror';
+	renderEngine: 'shiki'|'prismjs';
+	saveMode: 'onchange'|'oninput',
 	preferThemeColors: boolean;
 	inlineHighlighting: boolean;
 }
@@ -12,6 +15,9 @@ export const DEFAULT_SETTINGS: Settings = {
 	customThemeFolder: '',
 	customLanguageFolder: '',
 	theme: 'obsidian-theme',
+	renderMode: 'textarea',
+	renderEngine: 'shiki',
+	saveMode: 'onchange',
 	preferThemeColors: true,
 	inlineHighlighting: true,
 };
diff --git a/src/settings/SettingsTab.ts b/src/settings/SettingsTab.ts
index ce7044d..f8c0ab8 100644
--- a/src/settings/SettingsTab.ts
+++ b/src/settings/SettingsTab.ts
@@ -23,6 +23,18 @@ export class ShikiSettingsTab extends PluginSettingTab {
 			...builtInThemes,
 		};
 
+		this.containerEl.createEl('a', {
+			text: 'Settings Panel Document',
+			href: 'https://github.com/mProjectsCode/obsidian-shiki-plugin/blob/master/docs/README.md'
+		});
+		this.containerEl.createEl('span', {
+			text: ' | '
+		})
+		this.containerEl.createEl('a', {
+			text: 'Visual select theme',
+			href: 'https://textmate-grammars-themes.netlify.app'
+		});
+
 		new Setting(this.containerEl)
 			.setName('Reload Highlighter')
 			.setDesc('Reload the syntax highlighter. REQUIRED AFTER SETTINGS CHANGES.')
@@ -39,7 +51,7 @@ export class ShikiSettingsTab extends PluginSettingTab {
 
 		new Setting(this.containerEl)
 			.setName('Theme')
-			.setDesc('Select the theme for the code blocks.')
+			.setDesc('Select the theme for the code blocks (shiki).')
 			.addDropdown(dropdown => {
 				dropdown.addOptions(themes);
 				dropdown.setValue(this.plugin.settings.theme).onChange(async value => {
@@ -48,6 +60,50 @@ export class ShikiSettingsTab extends PluginSettingTab {
 				});
 			});
 
+		new Setting(this.containerEl)
+			.setName('Render Engine')
+			.setDesc('Select the render engine for the code blocks.')
+			.addDropdown(dropdown => {
+				dropdown.addOptions({
+					'shiki': 'Shiki',
+					'prismjs': 'PrismJs',
+				});
+				dropdown.setValue(this.plugin.settings.renderEngine).onChange(async value => {
+					this.plugin.settings.renderEngine = value as 'shiki'|'prismjs';
+					await this.plugin.saveSettings();
+				});
+			});
+
+		new Setting(this.containerEl)
+			.setName('Render Mode')
+			.setDesc('Select the render mode for the code blocks.')
+			.addDropdown(dropdown => {
+				dropdown.addOptions({
+					'textarea': 'textarea pre',
+					'pre': 'pre',
+					'editablePre': 'editable pre (beta)',
+					'codemirror': 'codemirror',
+				});
+				dropdown.setValue(this.plugin.settings.renderMode).onChange(async value => {
+					this.plugin.settings.renderMode = value as 'textarea'|'pre'|'editablePre'|'codemirror';
+					await this.plugin.saveSettings();
+				});
+			});
+
+		new Setting(this.containerEl)
+			.setName('Auto Save Mode')
+			.setDesc('Select the auto save mode for the code blocks.')
+			.addDropdown(dropdown => {
+				dropdown.addOptions({
+					'onchange': 'when change',
+					'oninput': 'when input',
+				});
+				dropdown.setValue(this.plugin.settings.saveMode).onChange(async value => {
+					this.plugin.settings.saveMode = value as 'onchange'|'oninput';
+					await this.plugin.saveSettings();
+				});
+			});
+
 		const customThemeFolderSetting = new Setting(this.containerEl)
 			.setName('Custom themes folder location')
 			.setDesc('Folder relative to your Vault where custom JSON theme files are located.')
diff --git a/styles.css b/styles.css
index 73aaaff..f951759 100644
--- a/styles.css
+++ b/styles.css
@@ -96,3 +96,310 @@ span.shiki-ul {
 .setting-item-control input.shiki-custom-theme-folder {
 	min-width: 250px;
 }
+/*
+ * shiki transformers style
+ * https://shiki.style/packages/transformers#unstyled
+ */
+/* color perset */
+:root {
+	--vp-c-gray-1: #dddde3;
+	--vp-c-gray-2: #e4e4e9;
+	--vp-c-gray-3: #ebebef;
+	--vp-c-gray-soft: rgba(142, 150, 170, .14);
+	--vp-c-indigo-1: #3451b2;
+	--vp-c-indigo-2: #3a5ccc;
+	--vp-c-indigo-3: #5672cd;
+	--vp-c-indigo-soft: rgba(100, 108, 255, .14);
+	--vp-c-purple-1: #6f42c1;
+	--vp-c-purple-2: #7e4cc9;
+	--vp-c-purple-3: #8e5cd9;
+	--vp-c-purple-soft: rgba(159, 122, 234, .14);
+	--vp-c-green-1: #18794e;;
+	--vp-c-green-2: #299764;
+	--vp-c-green-3: #30a46c;
+	--vp-c-green-soft: rgba(16, 185, 129, .14);;
+	--vp-c-yellow-1: #915930;
+	--vp-c-yellow-2: #946300;
+	--vp-c-yellow-3: #9f6a00;
+	--vp-c-yellow-soft: rgba(234, 179, 8, .14);
+	--vp-c-red-1: #b8272c;
+	--vp-c-red-2: #d5393e;
+	--vp-c-red-3: #e0575b;
+	--vp-c-red-soft: rgba(244, 63, 94, .14);
+}
+.dark {
+	--vp-c-gray-1: #515c67;
+	--vp-c-gray-2: #414853;
+	--vp-c-gray-3: #32363f;
+	--vp-c-gray-soft: rgba(101, 117, 133, .16);
+	--vp-c-indigo-1: #a8b1ff;
+	--vp-c-indigo-2: #5c73e7;
+	--vp-c-indigo-3: #3e63dd;
+	--vp-c-indigo-soft: rgba(100, 108, 255, .16);
+	--vp-c-purple-1: #c8abfa;
+	--vp-c-purple-2: #a879e6;
+	--vp-c-purple-3: #8e5cd9;
+	--vp-c-purple-soft: rgba(159, 122, 234, .16);
+	--vp-c-green-1: #3dd68c;;
+	--vp-c-green-2: #30a46c;
+	--vp-c-green-3: #298459;
+	--vp-c-green-soft: rgba(16, 185, 129, .16);;
+	--vp-c-yellow-1: #f9b44e;
+	--vp-c-yellow-2: #da8b17;
+	--vp-c-yellow-3: #a46a0a;
+	--vp-c-yellow-soft: rgba(234, 179, 8, .16);
+	--vp-c-red-1: #f66f81;;
+	--vp-c-red-2: #f14158;
+	--vp-c-red-3: #b62a3c;
+	--vp-c-red-soft: rgba(244, 63, 94, .16);;
+}
+
+/* Adapt to color preset */
+:root {
+	--vp-c-default-1: var(--vp-c-gray-1);
+	--vp-c-default-2: var(--vp-c-gray-2);
+	--vp-c-default-3: var(--vp-c-gray-3);
+	--vp-c-default-soft: var(--vp-c-gray-soft);;
+	--vp-c-brand-1: var(--vp-c-indigo-1);
+	--vp-c-brand-2: var(--vp-c-indigo-2);
+	--vp-c-brand-3: var(--vp-c-indigo-3);
+	--vp-c-brand-soft: var(--vp-c-indigo-soft);
+	--vp-c-brand: var(--vp-c-brand-1);
+	--vp-c-tip-1: var(--vp-c-brand-1);
+	--vp-c-tip-2: var(--vp-c-brand-2);
+	--vp-c-tip-3: var(--vp-c-brand-3);
+	--vp-c-tip-soft: var(--vp-c-brand-soft);
+	--vp-c-note-1: var(--vp-c-brand-1);
+	--vp-c-note-2: var(--vp-c-brand-2);
+	--vp-c-note-3: var(--vp-c-brand-3);
+	--vp-c-note-soft: var(--vp-c-brand-soft);
+	--vp-c-success-1: var(--vp-c-green-1);;
+	--vp-c-success-2: var(--vp-c-green-2);
+	--vp-c-success-3: var(--vp-c-green-3);
+	--vp-c-success-soft: var(--vp-c-green-soft);;
+	--vp-c-important-1: var(--vp-c-purple-1);
+	--vp-c-important-2: var(--vp-c-purple-2);
+	--vp-c-important-3: var(--vp-c-purple-3);
+	--vp-c-important-soft: var(--vp-c-purple-soft);
+	--vp-c-warning-1: var(--vp-c-yellow-1);
+	--vp-c-warning-2: var(--vp-c-yellow-2);
+	--vp-c-warning-3: var(--vp-c-yellow-3);
+	--vp-c-warning-soft: var(--vp-c-yellow-soft);;
+	--vp-c-danger-1: var(--vp-c-red-1);;
+	--vp-c-danger-2: var(--vp-c-red-2);
+	--vp-c-danger-3: var(--vp-c-red-3);
+	--vp-c-danger-soft: var(--vp-c-red-soft);;
+	--vp-c-caution-1: var(--vp-c-red-1);
+	--vp-c-caution-2: var(--vp-c-red-2);
+	--vp-c-caution-3: var(--vp-c-red-3);
+	--vp-c-caution-soft: var(--vp-c-red-soft)
+}
+
+/* Adapt to vuepress */
+:root {
+	--vp-code-line-height: 1.7;
+	--vp-code-font-size: .875em;
+	--vp-code-color: var(--vp-c-brand-1);
+	--vp-code-link-color: var(--vp-c-brand-1);
+	--vp-code-link-hover-color: var(--vp-c-brand-2);
+	--vp-code-bg: var(--vp-c-default-soft);
+	--vp-code-block-color: var(--vp-c-text-2);
+	--vp-code-block-bg: var(--vp-c-bg-alt);
+	--vp-code-block-divider-color: var(--vp-c-gutter);
+	--vp-code-lang-color: var(--vp-c-text-2);
+	--vp-code-line-highlight-color: var(--vp-c-default-soft);;
+	--vp-code-line-number-color: var(--vp-c-text-2);
+	--vp-code-line-diff-add-color: var(--vp-c-success-soft);;
+	--vp-code-line-diff-add-symbol-color: var(--vp-c-success-1);;
+	--vp-code-line-diff-remove-color: var(--vp-c-danger-soft);;
+	--vp-code-line-diff-remove-symbol-color: var(--vp-c-danger-1);;
+	--vp-code-line-warning-color: var(--vp-c-warning-soft);;
+	--vp-code-line-error-color: var(--vp-c-danger-soft);;
+	--vp-code-copy-code-border-color: var(--vp-c-divider);
+	--vp-code-copy-code-bg: var(--vp-c-bg-soft);
+	--vp-code-copy-code-hover-border-color: var(--vp-c-divider);
+	--vp-code-copy-code-hover-bg: var(--vp-c-bg);
+	--vp-code-copy-code-active-text: var(--vp-c-text-2);
+	--vp-code-copy-copied-text-content: "Copied";
+	--vp-code-tab-divider: var(--vp-code-block-divider-color);
+	--vp-code-tab-text-color: var(--vp-c-text-2);
+	--vp-code-tab-bg: var(--vp-code-block-bg);
+	--vp-code-tab-hover-text-color: var(--vp-c-text-1);
+	--vp-code-tab-active-text-color: var(--vp-c-text-1);
+	--vp-code-tab-active-bar-color: var(--vp-c-brand-1)
+}
+
+code>span.line.diff {
+	transition: background-color .5s;
+	margin: 0 calc(-1 * var(--shiki-x-padding));
+	padding: 0 var(--shiki-x-padding);
+	width: calc(100% + 2 * var(--shiki-x-padding));
+	display: inline-block
+}
+code>span.line.diff:before {
+	position: absolute;
+	left: 5px; /*old 10*/
+}
+code>span.line.diff.add {
+	background-color: var(--vp-code-line-diff-add-color);
+}
+code>span.line.diff.add:before {
+	content: "+";
+	color: var(--vp-code-line-diff-add-symbol-color)
+}
+code>span.line.diff.remove {
+	background-color: var(--vp-code-line-diff-remove-color);
+}
+code>span.line.diff.remove:before {
+	content: "-";
+	color: var(--vp-code-line-diff-remove-symbol-color)
+}
+code>span.line.highlighted {
+	background-color: var(--vp-code-line-highlight-color);
+	transition: background-color .5s;
+	margin: 0 calc(-1 * var(--shiki-x-padding));
+	padding: 0 var(--shiki-x-padding);
+	width: calc(100% + 2 * var(--shiki-x-padding));
+	display: inline-block
+}
+code>span.line.highlighted.error {
+	background-color: var(--vp-code-line-error-color)
+}
+code>span.line.highlighted.warning {
+	background-color: var(--vp-code-line-warning-color)
+}
+.has-focused code>span.line:not(.focused),
+.has-focused-lines code>span.line:not(.has-focus) {
+	filter:blur(.095rem);
+	opacity:.4;
+	transition:filter .35s,opacity .35s
+}
+.has-focused code>span.line:not(.focused),
+.has-focused-lines code>span.line:not(.has-focus) {
+	opacity:.7;
+	transition:filter .35s,opacity .35s
+}
+.has-focused:hover code>span.line:not(.focused),
+.has-focused-lines:hover code>span.line:not(.has-focus) {
+	filter:blur(0);
+	opacity:1
+}
+
+/* chore fix: keep color magin zero */
+code>span.line {
+	line-height: var(--shiki-line-height);
+}
+
+.obsidian-shiki-plugin {
+	position: relative;
+	min-width: 100%;
+}
+
+/* shiki attr style */
+.obsidian-shiki-plugin textarea {
+	position: absolute;
+	top: 0;
+	right: 0;
+	bottom: 0;
+	left: 0;
+	width: 100%;
+	height: 100%;
+	resize: none;
+	overflow: auto;
+	background-color: transparent;
+	/* color: transparent; */
+	color: #ffffff22;
+}
+
+/*
+ * keep same: pre & textare
+ * 
+ * It is necessary to ensure that the style of this part is not overwritten,
+ * Otherwise, `textarea` and `code` won't align
+ */
+:root {
+	--shiki-x-padding: 16px;
+	--shiki-line-height: 24px;
+ }
+.obsidian-shiki-plugin code, .obsidian-shiki-plugin textarea {
+	line-height: var(--shiki-line-height) !important;
+	tab-size: var(--indent-size) !important;
+	font-size: var(--code-size) !important;
+	font-family: var(--font-monospace) !important;
+	white-space: pre !important;
+}
+.obsidian-shiki-plugin code::selection, .obsidian-shiki-plugin textarea::selection {
+	background-color: var(--text-selection) !important;
+	color: currentColor !important
+}
+.obsidian-shiki-plugin code {
+	margin: 0 !important;
+	padding: 0 !important;
+	border: 0 !important;
+}
+.obsidian-shiki-plugin pre, .obsidian-shiki-plugin textarea {
+	margin: 0 !important;
+	padding: 12px var(--shiki-x-padding) !important;
+	border: 0 !important;
+	white-space: pre !important;
+	cursor: text;
+}
+/* edit-block-button > textarea > pre */
+.obsidian-shiki-plugin pre { z-index: 0; }
+.obsidian-shiki-plugin textarea { z-index: 0; }
+/* fix black line zero height */
+.obsidian-shiki-plugin code > span {
+	vertical-align: top !important;
+	min-height: var(--shiki-line-height) !important;
+}
+
+/* read mode / rendered */
+.markdown-preview-view .obsidian-shiki-plugin textarea,
+.markdown-rendered:not(.cm-preview-code-block.cm-embed-block) .obsidian-shiki-plugin textarea {
+	display: none;
+}
+.markdown-preview-view .obsidian-shiki-plugin pre,
+.markdown-rendered:not(.cm-preview-code-block.cm-embed-block) .obsidian-shiki-plugin pre {
+	margin-top: 16px !important;
+	margin-bottom: 16px !important;
+}
+
+/* language-type-btn */
+.obsidian-shiki-plugin .language-edit {
+	position: absolute;
+	bottom: 1px;
+	right: 0;
+	margin: 0;
+	padding: 0;
+	line-height: var(--shiki-line-height);
+	min-height: var(--shiki-line-height);
+	font-size: 13px;
+	opacity: 0.5;
+}
+.obsidian-shiki-plugin .language-edit>input {
+	margin: 0;
+	padding: 0 14px;
+	border: none;
+	background: none;
+	box-shadow: none;
+	text-align: right;
+}
+
+/* pdf. Avoid displaying metadata in pdf exports. */
+.print .mod-frontmatter {
+	display: none !important;
+}
+
+.is-no-saved::before {
+	content: "";
+	position: absolute;
+	width: 2px;
+	height: 100%;
+	left: 0;
+	top: 0;
+	background-color: currentColor;
+	opacity: 0.5;
+	z-index: 10;
+	
+	/* border-left: solid 2px yellow !important; */
+}
diff --git a/tsconfig.json b/tsconfig.json
index fcdeefd..6873044 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -11,7 +11,7 @@
 		"moduleResolution": "node",
 		"importHelpers": true,
 		"isolatedModules": true,
-		"lib": ["DOM", "ESNext"],
+		"lib": ["DOM", "ESNext", "DOM.Iterable"],
 		"allowSyntheticDefaultImports": true
 	},
 	"include": ["src/**/*.ts", "tests/**/*.ts"]