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/.gitignore b/.gitignore index 0a682a0..a73d0e5 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,10 @@ exampleVault/.obsidian/* exampleVault/.obsidian/plugins/* exampleVault/.obsidian/plugins/lemons-plugin-template/* -!exampleVault/.obsidian/plugins/lemons-plugin-template/.hotreload \ No newline at end of file +!exampleVault/.obsidian/plugins/lemons-plugin-template/.hotreload + +/main.css +/styles.css +/tmp +/temp +/backup 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/automation/build/esbuild.config.ts b/automation/build/esbuild.config.ts index b7df51d..e284a39 100644 --- a/automation/build/esbuild.config.ts +++ b/automation/build/esbuild.config.ts @@ -52,4 +52,14 @@ const build = await esbuild.build({ const file = Bun.file('meta.txt'); await Bun.write(file, JSON.stringify(build.metafile, null, '\t')); +// css +await esbuild.build({ + entryPoints: ["custom.css"], + outfile: "styles.css", + // watch: !prod, // 似乎若升级esbuild后不再支持 + bundle: true, + allowOverwrite: true, + minify: false, +}); + 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/styles.css b/custom.css similarity index 81% rename from styles.css rename to custom.css index 73aaaff..a63ad74 100644 --- a/styles.css +++ b/custom.css @@ -1,3 +1,7 @@ +/* Direct editing of styles.css is not allowed. Instead, the file should be edited. */ + +@import url("src/general/EditableCodeblock.css"); + body { --shiki-code-background: var(--code-background); --shiki-code-normal: var(--text-muted); @@ -96,3 +100,19 @@ span.shiki-ul { .setting-item-control input.shiki-custom-theme-folder { min-width: 250px; } + +/* read mode / rendered */ +.markdown-preview-view .editable-codeblock textarea, +.markdown-rendered:not(.cm-preview-code-block.cm-embed-block) .editable-codeblock textarea { + display: none; +} +.markdown-preview-view .editable-codeblock pre, +.markdown-rendered:not(.cm-preview-code-block.cm-embed-block) .editable-codeblock pre { + margin-top: 16px !important; + margin-bottom: 16px !important; +} + +/* pdf. Avoid displaying metadata in pdf exports. */ +.print .mod-frontmatter { + display: none !important; +} 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/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/general/EditableCodeblock.css b/src/general/EditableCodeblock.css new file mode 100644 index 0000000..cbe668c --- /dev/null +++ b/src/general/EditableCodeblock.css @@ -0,0 +1,328 @@ +/* + * shiki transformers style + * https://shiki.style/packages/transformers#unstyled + */ + +/* ----------------------------- adapt without obsidian ------------ */ + +/* +[!code hl] +var(--code-size) +*/ +/* :root { + --font-monospace: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --text-selection: #0078D788; + --indent-size: 4; +} */ + +/* ----------------------------- adapt without vuepress ------------- */ + +/* 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) +} + +/* ----------------------------- line flag -------------------------- */ + +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 +} + +/* ----------------------------- keep same in textarePre ------------ */ + +/* + * keep same: pre & textarea + * + * 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; + } +.editable-codeblock code, .editable-codeblock 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; +} +.editable-codeblock code::selection, .editable-codeblock textarea::selection { + background-color: var(--text-selection) !important; + color: currentColor !important +} +.editable-codeblock code { + display: block !important; + margin: 0 !important; + padding: 0 !important; + border: 0 !important; +} +.editable-codeblock pre, .editable-codeblock textarea { + display: block !important; + box-sizing: border-box !important; + margin: 0 !important; + padding: 12px var(--shiki-x-padding) !important; + border: 0 !important; + white-space: pre !important; + cursor: text; +} +/* edit-block-button > textarea > pre */ +.editable-codeblock pre { z-index: 0; } +.editable-codeblock textarea { z-index: 0; } +/* fix black line zero height */ +.editable-codeblock code > span { + vertical-align: top !important; + min-height: var(--shiki-line-height) !important; +} + +/* ----------------------------- other ------------------------------ */ + +/* language-type-btn */ +.editable-codeblock .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; +} +.editable-codeblock .language-edit>input { + margin: 0; + padding: 0 14px; + border: none; + background: none; + box-shadow: none; + text-align: right; + color: currentColor; +} + +/* chore fix: keep color magin zero */ +code>span.line { + line-height: var(--shiki-line-height); +} + +.editable-codeblock { + position: relative; + min-width: 100%; +} + +/* shiki attr style */ +.editable-codeblock 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; + caret-color: #ffffffdd; +} +.editable-codeblock textarea:focus { + /* outline: none; */ + outline: 1px solid #0060DF; + border-radius: 4px; +} +.editable-codeblock pre:focus, .editable-codeblock pre:has(> code:focus) { + outline: 1px solid #0060DF; +} +.editable-codeblock code:focus { + outline: none; +} + +.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/src/general/EditableCodeblock.ts b/src/general/EditableCodeblock.ts new file mode 100644 index 0000000..b0a0d73 --- /dev/null +++ b/src/general/EditableCodeblock.ts @@ -0,0 +1,805 @@ +/** + * General editable code blocks based on shiki/prismjs + * + * This module previously relied on the `Obsidian` API, + * but has now been changed to a general module: editable code blocks. + * + * TODO: fix textareaPre x-scrool + */ + +// [!code hl] +import 'src/general/EditableCodeblock.css' +import { LLOG } from 'src/general/LLogInOb'; +import { + transformerNotationDiff, + transformerNotationHighlight, + transformerNotationFocus, + transformerNotationErrorLevel, + transformerNotationWordHighlight, + + transformerMetaHighlight, + transformerMetaWordHighlight, +} from '@shikijs/transformers'; +import { bundledThemesInfo, codeToHtml } from 'shiki'; // 8.6MB +import type Prism from 'prismjs'; + +export const loadPrism2 = { + // can override + fn: async (): Promise => { + // import Prism from "prismjs" + // return Prism + + return null + } +} + +/** Outer info + * + * Outside editor (option, use when codeblock in another editable area) + * from ctx.getSectionInfo(el) // [!code warning] There may be indentation + * + * Life cycle: One codeblock has one. + * Pay attention to consistency. + */ +export interface OuterInfo { + 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, +} + +/** Inner info + * + * From obsidian callback args // [!code warning] It might be old data in oninput/onchange method + * + * Life cycle: One codeblock has one. + * Pay attention to consistency. + */ +export interface InnerInfo { + language_old: string, // to lib, can't be an alias (eg. c++ -> cpp, sh -> shell, ...) + 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 + * + * use by extends: export default class EditableCodebloc2 extends EditableCodeblock { + * override enable_editarea_listener + * override emit_render + * override emit_save + * } + */ +export class EditableCodeblock { + // - el: (container) + // - thisEl: .editable-codeblock + el: HTMLElement; + // thisEl: HTMLElement; + + // 丢弃依赖 + // plugin: { app: App; settings: Settings }; + // 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 + + // redundancy + isReadingMode: boolean = false; // uneditable when true + isMarkdownRendered: boolean = false; // uneditable when true + settings: { + theme: string; + renderMode: 'textarea'|'pre'|'editablePre'|'codemirror'; + renderEngine: 'shiki'|'prismjs'; + saveMode: 'onchange'|'oninput' + } = { + theme: 'obsidian-theme', + renderMode: 'textarea', + renderEngine: 'shiki', + saveMode: 'onchange' + } + config: { + useTab: boolean; + tabSize: number; + } = { + useTab: true, + tabSize: 4, + } + + innerInfo: InnerInfo; + outerInfo: OuterInfo; + + constructor(language_old:string, source_old:string, el:HTMLElement) { + // 丢弃依赖 + // this.plugin = plugin + // this.ctx = ctx + // 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.el = el + this.innerInfo = { + language_old: language_old, + source_old: source_old, + } + this.outerInfo = this.init_outerInfo(language_old) + this.outerInfo.source = this.innerInfo.source_old + } + + /// (can override) + init_outerInfo(language_old:string): OuterInfo { + return { + prefix: '', + flag: '', // null flag + language_meta: '', + language_type: language_old, + source: null, // null flag + } + } + + /// first render + render(): void { + switch (this.settings.renderMode) { + case 'textarea': + this.renderTextareaPre() + break + case 'pre': + this.emit_render(this.el) + break + case 'editablePre': + this.renderEditablePre() + break + default: + throw new Error('Unreachable') + } + } + + /** param this.settings.saveMode onchange/oninput + * + * onCall: renderMode === 'textarea' + */ + private renderTextareaPre(): void { + // dom + // - div.editable-codeblock + // - span > pre > code + // - textarea + // - div.language-edit + + // div + const div = document.createElement('div'); this.el.appendChild(div); div.classList.add('editable-codeblock', 'editable-textarea') + + // span + const span = document.createElement('span'); div.appendChild(span); + void this.emit_render(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.outerInfo.source ?? this.innerInfo.source_old; + + // [!code hl] + // language-edit + let editInput: HTMLInputElement|undefined + const editEl = document.createElement('div'); div.appendChild(editEl); editEl.classList.add('language-edit'); + editEl.setAttribute('align', 'right'); + editInput = document.createElement('input'); editEl.appendChild(editInput); + editInput.value = this.outerInfo.language_type + this.outerInfo.language_meta + + // readmode and markdown reRender not shouldn't change + if (this.isReadingMode || this.isMarkdownRendered) { + textarea.setAttribute('readonly', '') + textarea.setAttribute('display', '') + if (editInput) { editInput.setAttribute('readonly', '') } + return + } + + // ---------- is sync -------------- + + // #region textarea - async part - composition start/end + let isComposing = false; // Is in the input method combination stage. Can fix input method (like chinese) invalid. The v-model in the Vue version also has this problem. + 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.settings.saveMode == 'onchange') { + textarea.oninput = (ev): void => { + emit_change((ev.target as HTMLTextAreaElement).value, true, false, false) + } + // TODO: fix: not emit onchange when no change, and is-no-saved class will not remove. + textarea.onchange = (ev): void => { // save must on oninput: avoid: textarea --update--> source update --update--> textarea (lose curosr position) + emit_change((ev.target as HTMLTextAreaElement).value, true, true, false) + } + } + // 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 => { + emit_change((ev.target as HTMLTextAreaElement).value, true, true, true) // old: isRender is true + } + } + + // Note: Saving does not necessarily trigger rendering (this is only the case in environments such as ob) + const emit_change = (newValue: string, isRender: boolean, isSave: boolean, isSavePos: boolean): void => { + if (isComposing) return + this.outerInfo.source = newValue + if (isRender) { + void this.emit_render(span) + div.classList.add('is-no-saved'); + } + if (isSavePos) { + global_refresh_cache = { + start: textarea.selectionStart, + end: textarea.selectionEnd, + } + } + if (isSave) { + div.classList.remove('is-no-saved'); void this.emit_save(false, true) + } + } + // #endregion + + // #region textarea - async part - keydown + this.enable_editarea_listener(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 + if (editInput) { + if (this.settings.saveMode != 'oninput') { + // no support + } + { + editInput.oninput = (ev): void => { + if (isComposing) return + + 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.outerInfo.language_type = match[1] + this.outerInfo.language_meta = match[2] + void this.emit_render(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.outerInfo.language_type = match[1] + this.outerInfo.language_meta = match[2] + div.classList.remove('is-no-saved'); void this.emit_save(true, false) + } + } + + this.enable_editarea_listener(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.settings.saveMode onchange/oninput + * + * onCall: renderMode === 'editablePre' + * + * ## editable pre or code? + * + * - (default) editable pre + * - bug: pre maybe has multiple code el (firefox no, chrome will) + * fix: ??? (There is no solution for the time being) + * - editable code + * - bug: focus area for chick more small + * fix: code display inline->block + * - bug: cursor in lastline when newLine + * fix: fix progarm bug + * - bug: hard to use focus style + * fix: `:has()` (only support in new version browser) + */ + private async renderEditablePre(): Promise { + // dom + // - div.editable-codeblock.editable-pre + // - pre + // - code.language- + + // div + const div = document.createElement('div'); this.el.appendChild(div); div.classList.add('editable-codeblock', 'editable-pre') + + // pre, code + await this.emit_render(div) + let pre: HTMLPreElement|null = div.querySelector(':scope>pre') + let code: HTMLPreElement|null = div.querySelector(':scope>pre>code') + if (!pre || !code) { LLOG.error('render failed. can\'t find pre/code 1'); return } + this.enable_editarea_listener(code) + + // 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.settings.saveMode == 'onchange') { + void Promise.resolve().then(() => { + if (!global_refresh_cache) return + if (!pre || !code) { LLOG.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 => { + emit_change((ev.target as HTMLPreElement).innerText, true, false, false) // .textContent more fast, but can't get new line by 'return' (\n yes, br no) + } + // 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) + emit_change((ev.target as HTMLPreElement).innerText, false, true, false) // .textContent more fast, but can't get new line by 'return' (\n yes, br no) + }) + } + // refresh/save strategy2: cache and rebuild + else { + void Promise.resolve().then(() => { + if (!global_refresh_cache) return + if (!pre || !code) { LLOG.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 => { + emit_change((ev.target as HTMLPreElement).innerText, false, true, true) // .textContent more fast, but can't get new line by 'return' (\n yes, br no) + // old save more simple: void this.emit_render(div) + } + } + + // Note: Saving does not necessarily trigger rendering (this is only the case in environments such as ob). + const emit_change = (newValue: string, isRender: boolean, isSave: boolean, isSavePos: boolean): void => { + if (isComposing) return + if (!pre || !code) { LLOG.error('render failed. can\'t find pre/code 12'); return } + + this.outerInfo.source = newValue // prism use textContent and shiki use innerHTML, Their escapes from `` are different + if (isRender) { + 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) { LLOG.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.emit_render(div) + div.classList.add('is-no-saved'); + + // restore pos + if (!global_refresh_cache) return + this.renderEditablePre_restoreCursorPosition(pre, global_refresh_cache.start, global_refresh_cache.end) + global_refresh_cache = null + }) + } + if (isSavePos) { + global_refresh_cache = this.renderEditablePre_saveCursorPosition(pre) + } + if (isSave) { + div.classList.remove('is-no-saved'); void this.emit_save(false, true) + } + } + // #endregion + } + + private 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) // first node, startOffset is range, endOffset maybe error + + // 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 + } + } + + private 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) + } + + /// TODO: fix: after edit, can't up/down to root editor + /// @param el: HTMLTextAreaElement|HTMLInputElement|HTMLPreElement + protected enable_editarea_listener(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 + + el.addEventListener('focus', () => { + // update about outter + }) + + // #region textarea - async part - keydown + el.addEventListener('keydown', (ev: KeyboardEvent) => { // `tab` key、~~`arrow` key~~ + // TODO add shift tab + // var name: (`[` `]` represents the cursors at both ends) + // ABCD + // (2) AB[(1)CD(3) // selectionStart, selectionStart_start, selectionStart_end + // (5) AB](4)CD(6) // selectionEnd, selectionEnd_start, selectionEnd_end + // ABCD + if (ev.key == 'Tab') { + if (cb_tab) { cb_tab(ev); return } + ev.preventDefault() + const isShiftTab: boolean = ev.shiftKey; + + // get indent + const indent_space = ' '.repeat(this.config.tabSize) + let indent = this.config.useTab ? '\t' : indent_space + + if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { + const nodeText: string = el.value + const selectionStart: number = el.selectionStart ?? 0 + const selectionEnd: number = el.selectionEnd ?? 0 + const selectionStart_start: number = nodeText.lastIndexOf('\n', selectionStart - 1) + 1 // fix -1 by +1 + let selectionEnd_end: number = nodeText.indexOf('\n', selectionEnd) // fix -1 by use text length + if (selectionEnd_end == -1) selectionEnd_end = nodeText.length + + // auto indent (otpion) + { + const selectionStart_line: string = nodeText.substring(selectionStart_start, selectionEnd_end) + if (selectionStart_line.startsWith('\t')) indent = '\t' + else if (selectionStart_line.startsWith(' ')) indent = indent_space + } + + // change - indent, if selected content + if (selectionStart != selectionEnd || isShiftTab) { + const before = nodeText.substring(0, selectionStart_start) // maybe with end `\n` + const after = nodeText.substring(selectionEnd_end) // maybe with start `\n` + let center = nodeText.substring(selectionStart_start, selectionEnd_end) // without both `\n` + const center_lines = center.split('\n') + + if (isShiftTab) { + // new value: before + newCenter + after + let indent_subCount = 0 + const indent_subCount_start: 0|1 = center_lines[0].startsWith(indent) ? 1 : 0 + center = center_lines.map(line => { + if (line.startsWith(indent)) { + indent_subCount++ + return line.slice(indent.length); + } + return line + }).join('\n') + el.value = before + center + after + + // new cursor pos + el.selectionStart = selectionStart - indent.length * indent_subCount_start + el.selectionEnd = selectionEnd - indent.length * indent_subCount + } + else { + center = center_lines.map(line => indent + line).join('\n') + el.value = before + center + after + + // new cursor pos + el.selectionStart = selectionStart + indent.length * 1 + el.selectionEnd = selectionEnd + indent.length * center_lines.length + } + } + // change - insert + else { + // 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 + } + } + else { // pre/code + let pre: HTMLPreElement + let code: HTMLPreElement + if (el.tagName === 'CODE') { + pre = el.parentElement as HTMLPreElement + code = el as HTMLPreElement + } + else { + pre = el as HTMLPreElement + code = el.querySelector(':scope>code') as HTMLPreElement + } + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return + + const nodeText = pre.textContent ?? '' + const pos = this.renderEditablePre_saveCursorPosition(pre) + if (!pos) return + const selectionStart: number = pos.start + const selectionEnd: number = pos.end + const selectionStart_start: number = nodeText.lastIndexOf('\n', selectionStart - 1) + 1 // fix -1 by +1 + let selectionEnd_end: number = nodeText.indexOf('\n', selectionEnd) // fix -1 by use text length + if (selectionEnd_end == -1) selectionEnd_end = nodeText.length + + // auto indent (otpion) + { + const selectionStart_line: string = nodeText.substring(selectionStart_start, selectionEnd_end) + if (selectionStart_line.startsWith('\t')) indent = '\t' + else if (selectionStart_line.startsWith(' ')) indent = indent_space + } + + // change - indent, if selected content + if (selectionStart != selectionEnd || isShiftTab) { + const before = nodeText.substring(0, selectionStart_start) // maybe with end `\n` + const after = nodeText.substring(selectionEnd_end) // maybe with start `\n` + let center = nodeText.substring(selectionStart_start, selectionEnd_end) // without both `\n` + const center_lines = center.split('\n') + + if (isShiftTab) { + // new value: before + newCenter + after + let indent_subCount = 0 + const indent_subCount_start: 0|1 = center_lines[0].startsWith(indent) ? 1 : 0 + center = center_lines.map(line => { + if (line.startsWith(indent)) { + indent_subCount++ + return line.slice(indent.length); + } + return line + }).join('\n') + this.outerInfo.source = before + center + after + + // new cursor pos TODO + global_refresh_cache = { + start: selectionStart - indent.length * indent_subCount_start, + end: selectionEnd - indent.length * indent_subCount + } + } + else { + center = center_lines.map(line => indent + line).join('\n') + this.outerInfo.source = before + center + after + + // new cursor pos TODO + global_refresh_cache = { + start: selectionStart + indent.length * 1, + end: selectionEnd + indent.length * center_lines.length + } + } + + code.innerText = before + center + after + } + // change - insert + else { + // new value + const selectionRange = selection.getRangeAt(0) // first node, .startOffset is right, .endOffset maybe error + const textNode: Node = document.createTextNode(indent) + selectionRange.deleteContents() + selectionRange.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', { // emit input event + inputType: 'insertText', + data: indent, + bubbles: true, + cancelable: true + })); + return + } + }) + // #endregion + } + + // /** + // * @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) + // LLOG.log('debug renderPre debounced') + // }, 200) + + /** Render code to targetEl + * + * onCall: renderMode === 'pre' + * + * why reuse code element? + * - reduce the refresh rate + * - avoid rebind event listeners, like focus, blur, input, keydown, etc. + * - avoid re focus + * + * Otherwise, additional work will be required later on: + * - this.enable_editarea_listener(code) + * - ... rebind event + * + * param this.settings.renderEngine shiki/prism + * @param targetEl in which element should the result be rendered + * - targetEl (usually a div) + * - pre + * - code + */ + async emit_render(targetEl: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.outerInfo.source ?? this.innerInfo.source_old + if (source.endsWith('\n')) source += '\n' + + // pre html string - shiki, insert `
...
`
+		if (this.settings.renderEngine == 'shiki') {
+			// [!code hl]
+			// check theme, TODO: use more theme
+			let theme = ''
+			for (const item of bundledThemesInfo) {
+				if (item.id == this.settings.theme) { theme = this.settings.theme; break }
+			}
+			if (theme === '') {
+				theme = 'andromeeda'
+				// LLOG.warn(`no support theme '${this.settings.theme}' temp in this render mode`) // [!code error] TODO fix
+			}
+
+			const preStr:string = await codeToHtml(source, {
+				lang: this.innerInfo.language_old,
+				theme: theme,
+				meta: { __raw: this.outerInfo.language_meta },
+				// https://shiki.style/packages/transformers
+				transformers: [
+					transformerNotationDiff({ matchAlgorithm: 'v3' }),
+					transformerNotationHighlight(),
+					transformerNotationFocus(),
+					transformerNotationErrorLevel(),
+					transformerNotationWordHighlight(),
+
+					transformerMetaHighlight(),
+					transformerMetaWordHighlight(),
+				],
+			})
+
+			const code: HTMLPreElement|null = targetEl.querySelector(':scope>pre>code')
+			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) { LLOG.error('shiki return preStr without code tag', doc); return }
+				code.innerHTML = codeElement.innerHTML
+			}
+		}
+		// pre html string - prism, insert `
...
`
+		else {
+			const prism = await loadPrism2.fn() as typeof Prism|null;
+			if (!prism) {
+				LLOG.error('warning: withou Prism')
+				return
+			}
+
+			// sure `targetEl > pre> one code`
+			let pre: HTMLPreElement|null = targetEl.querySelector(':scope>pre')
+			let code: HTMLPreElement|null = targetEl.querySelector(':scope>pre>code')
+			if (!code) {
+				targetEl.innerHTML = ''
+				pre = document.createElement('pre'); targetEl.appendChild(pre);
+				code = document.createElement('code') as HTMLPreElement; pre.appendChild(code); code.classList.add('language-'+this.outerInfo.language_type);
+				code.setAttribute('contenteditable', 'true'); code.setAttribute('spellcheck', 'false')
+			} else if (!pre) {
+				targetEl.innerHTML = ''
+				const pre = document.createElement('pre'); targetEl.appendChild(pre);
+				pre.appendChild(code)
+			} else if (pre.children?.length??0 > 1) {
+				pre.innerHTML = ''
+				pre.appendChild(code)
+			} else {
+				// nothing
+			}
+
+			// render
+			code.textContent = source; // prism use textContent and shiki use innerHTML, Their escapes from `` are different
+			prism.highlightElement(code)
+		}
+	}
+
+	/** Save textarea text content to codeBlock markdown source
+	 * 
+	 * 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
+	 */
+	emit_save(isUpdateLanguage: boolean, isUpdateSource: boolean): Promise {
+		return new Promise((resolve, reject) => {})
+	}
+}
diff --git a/src/general/EditableCodeblockInOb.ts b/src/general/EditableCodeblockInOb.ts
new file mode 100644
index 0000000..50db086
--- /dev/null
+++ b/src/general/EditableCodeblockInOb.ts
@@ -0,0 +1,443 @@
+import { EditableCodeblock, loadPrism2, type OuterInfo } from 'src/general/EditableCodeblock'
+
+// add `obsidian` and `cm` dependencies
+import {
+	type App,
+	debounce,
+	type Editor,
+	loadPrism,
+	type MarkdownPostProcessorContext,
+	MarkdownRenderer,
+	MarkdownRenderChild,
+	MarkdownView,
+} from 'obsidian';
+import { type Settings } from 'src/settings/Settings';
+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 { LLOG } from 'src/general/LLogInOb';
+
+loadPrism2.fn = loadPrism
+
+const reg_code = /^((\s|>\s|-\s|\*\s|\+\s)*)(```+|~~~+)(\S*)(\s?.*)/
+// const reg_code_noprefix = /^((\s)*)(```+|~~~+)(\S*)(\s?.*)/
+
+export default class EditableCodeblockInOb extends EditableCodeblock {
+	// 新增依赖
+	plugin: { app: App; settings: Settings };
+	ctx: MarkdownPostProcessorContext;
+	editor: Editor|null = 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
+
+	constructor(plugin: { app: App; settings: Settings }, language_old:string, source_old:string, el:HTMLElement, ctx:MarkdownPostProcessorContext) {
+		super(language_old, source_old, el)
+
+		// 新增依赖
+		this.plugin = plugin
+		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.settings = this.plugin.settings
+
+		// override
+		this.outerInfo = this.init_outerInfo2(language_old, source_old, el, ctx)
+	}
+
+	/// TODO: fix: after edit, can't up/down to root editor
+	/// @param el: HTMLTextAreaElement|HTMLInputElement|HTMLPreElement
+	override enable_editarea_listener(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
+
+		super.enable_editarea_listener(el, cb_tab, cb_up, cb_down) // `tab`
+
+		el.addEventListener('focus', () => {
+			// update_outEditor()
+			this.editor = this.plugin.app.workspace.activeEditor?.editor ?? null
+		})
+
+		// textarea - async part - keydown
+		el.addEventListener('keydown', (ev: KeyboardEvent) => { // ~~`tab` key~~、`arrow` key	
+			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
+			}*/
+		})
+	}
+
+	override async emit_save(isUpdateLanguage: boolean = true, isUpdateSource: boolean = true): Promise {
+		/**
+		 * @deprecated You should use `saveContent_safe` version
+		 */
+		const saveContent_debounced = debounce(async (isUpdateLanguage: boolean = true, isUpdateSource: boolean = true) => {
+			void this.emit_save_unsafe(isUpdateLanguage, isUpdateSource)
+		}, 200)
+
+		// [!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.settings.saveMode == 'oninput') {
+			void this.emit_save_unsafe(isUpdateLanguage, isUpdateSource)
+		}
+		else if (this.settings.saveMode == 'onchange') {
+			void saveContent_debounced(isUpdateLanguage, isUpdateSource)
+		}
+	}
+
+	/**
+	 * @deprecated can't save when cursor in codeblock and use short-key switch to source mode.
+	 * You should use `saveContent_safe` version
+	 */
+	async emit_save_unsafe(isUpdateLanguage: boolean = true, isUpdateSource: boolean = true): Promise {
+		// range
+		const sectionInfo = this.ctx.getSectionInfo(this.el);
+		if (!sectionInfo) {
+			LLOG.error("Warning: without el section!")
+			return;
+		}
+		// sectionInfo.lineStart; // index in (```)
+		// sectionInfo.lineEnd;   // index in (```), Let's not modify the fence part
+
+		// editor
+		if (!this.editor) {
+			LLOG.error("Warning: without editor!")
+			return;
+		}
+
+		// change - language
+		if (isUpdateLanguage) {
+			this.editor.transaction({
+				changes: [{
+					from: {line: sectionInfo.lineStart, ch: 0},
+					to: {line: sectionInfo.lineStart+1, ch: 0},
+					text: this.outerInfo.flag + this.outerInfo.language_type + this.outerInfo.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.outerInfo.source ?? this.innerInfo.source_old) + '\n'
+				}],
+			});
+		}
+	}
+
+	// ---------------------- ex, no override -------------------------
+
+	private init_outerInfo2(language_old:string, source_old:string, el:HTMLElement, ctx: MarkdownPostProcessorContext): OuterInfo {
+		const sectionInfo = ctx.getSectionInfo(el);
+		if (!sectionInfo) {
+			// This is possible. when rerender
+			const outerInfo:OuterInfo = {
+				prefix: '',
+				flag: '', // null flag
+				language_meta: '',
+				language_type: language_old,
+				source: null, // null flag
+			}
+			return outerInfo
+		}
+		// 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.
+			LLOG.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 outerInfo:OuterInfo = {
+				prefix: '',
+				flag: '', // null flag
+				language_meta: '',
+				language_type: language_old,
+				source: null, // null flag
+			}
+			return outerInfo
+		}
+
+		const outerInfo:OuterInfo = {
+			prefix: match[1],
+			flag: match[3],
+			language_meta: match[5],
+			language_type: match[4],
+			source: lines.slice(sectionInfo.lineStart + 1, sectionInfo.lineEnd).join('\n'),
+		}
+		return outerInfo
+	}
+
+	/** editable callout
+	 * 
+	 * onCall: language.startWith('sk-')
+	 */
+	renderCallout(): void {
+		// - div
+		//   - divCallout
+		//     - divTitle
+		//       - divIcon
+		//       - divInner
+		//     - divContent
+		//       - ( ) b1 .markdown-rendered
+		//       - ( ) b2 .cm-editor > .cm-scroller > div.contenteditable
+		//   - divEditBtn
+
+		const 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.outerInfo.source ?? this.innerInfo.source_old, divRender, this.plugin.app.workspace.getActiveViewOfType(MarkdownView)?.file?.path??"", mdrc)
+		}
+
+		// 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.outerInfo.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.outerInfo.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 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.outerInfo.source = cm.state.doc.toString()
+						void 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.emit_save(false, true);
+					},
+					(cm: EditorView) => {
+						this.outerInfo.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.emit_save(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.outerInfo.source ?? '')
+				}
+				else {
+					// Strategy 4 use ob extensions, but without ob style
+					const cmState = EditorState.create({
+						doc: this.outerInfo.source ?? this.innerInfo.source_old,
+						extensions: [
+							basicSetup,
+							// keymap.of(defaultKeymap),
+							markdown(),
+							EditorView.updateListener.of(update => {
+								if (update.docChanged) {
+									this.outerInfo.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) {
+						LLOG.warn('can\'t find elCmEditor')
+						return
+					}
+					elCmEditor.focus()
+					elCmEditor.addEventListener('blur', (): void => {
+						void renderMarkdown(divContent) // if save but nochange, will not rerender. So it is needed.
+
+						div.classList.remove('is-no-saved'); void this.emit_save(false, true);
+					})
+				}
+			}
+
+			// if (global_isLiveMode_cache) {
+			// global_isLiveMode_cache = false // TODO can add option, default cm or readmode
+			embedEditor()
+			divContent.addEventListener('dblclick', () => { embedEditor() })
+		}
+		// #endregion
+	}
+}
diff --git a/src/general/LLog.ts b/src/general/LLog.ts
new file mode 100644
index 0000000..959baae
--- /dev/null
+++ b/src/general/LLog.ts
@@ -0,0 +1,67 @@
+/**
+ * A simple, general, log tool
+ * 
+ * feat:
+ * - level
+ * - log & notice & file
+ */
+
+export type LogLevel = "debug" | "info" | "warn" | "error" | "none";
+export type LogLevel2 = "debug" | "info" | "warn" | "error";
+
+interface LLog_Config {
+  level?: LogLevel;              // min output level
+  enableTimestamp?: boolean;     // is output timestamp
+  tag?: string;                  // tag prefix
+}
+
+const levelOrder: LogLevel[] = ["debug", "info", "warn", "error", "none"];
+
+export class LLog {
+  config: Required = {
+    level: "debug",
+    enableTimestamp: true,
+    tag: "",
+  }
+
+  set_config(cfg: LLog_Config): void {
+    this.config = { ...this.config, ...cfg }
+  }
+
+  debug(...args: unknown[]): void {
+    this.logCore("debug", ...args)
+  }
+
+  info(...args: unknown[]): void {
+    this.logCore("info", ...args)
+  }
+
+  warn(...args: unknown[]): void {
+    this.logCore("warn", ...args)
+  }
+
+  error(...args: unknown[]): void {
+    this.logCore("error", ...args)
+  }
+
+  static consoleMap = {
+    debug: console.log,
+    info: console.info,
+    warn: console.warn,
+    error: console.error,
+  } as const;
+
+  // can override
+  /// // @return 返回打印内容。可以通过这种方式链式调用添加Notice等操作,进行多重输出
+  logCore(level: LogLevel2, ...args: unknown[]): void {
+    if (levelOrder.indexOf(level) < levelOrder.indexOf(this.config.level)) return
+
+    const now = this.config.enableTimestamp ? `[${new Date().toISOString()}]` : ""
+    const tag = this.config.tag ? `[${this.config.tag}]` : ""
+    const prefix = [now, tag, `[${level.toUpperCase()}]`].filter(Boolean).join(" ")
+
+    LLog.consoleMap[level]?.(prefix, ...args)
+  }
+}
+// Provide an object that is ready to use out of the box.
+export const LLOG = new LLog()
diff --git a/src/general/LLogInOb.ts b/src/general/LLogInOb.ts
new file mode 100644
index 0000000..eb52577
--- /dev/null
+++ b/src/general/LLogInOb.ts
@@ -0,0 +1,18 @@
+import { LLog, type LogLevel2 } from 'src/general/LLog'
+import {
+	Notice,
+} from 'obsidian';
+
+class LLogInOb extends LLog {
+  logCore(level: LogLevel2, ...args: unknown[]): void {
+    super.logCore(level, ...args)
+
+    // If error, it is necessary to clearly inform the users
+    if (level != "error") return
+    if (args[0] && typeof args[0] === 'string') {
+      new Notice(args[0], 3000)
+    }
+  }
+}
+// Provide an object that is ready to use out of the box.
+export const LLOG = new LLogInOb()
diff --git a/src/general/README.md b/src/general/README.md
new file mode 100644
index 0000000..32901b2
--- /dev/null
+++ b/src/general/README.md
@@ -0,0 +1,10 @@
+# README
+
+General, shared, and utils are all common parts, but here the specification is: 
+- general: Must be unrelated to this project. The contents in this folder can be moved to other projects for reuse.
+- shared: Multiple components need to share.
+- utils: Small tools. Since they are all small tools, they can sometimes be reused, but they do not emphasize reusability.
+
+`InOb` version: 
+
+- Convert the general module into a module with `Obsidian` dependencies and replace the general module.
diff --git a/src/main.min.ts b/src/main.min.ts
new file mode 100644
index 0000000..5476ecb
--- /dev/null
+++ b/src/main.min.ts
@@ -0,0 +1,201 @@
+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/general/EditableCodeblockInOb'
+
+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
+						if (language.startsWith('sk-')) { // editable callout
+							const editableCodeblock = new EditableCodeblock(this, language, source, el, ctx)
+							editableCodeblock.renderCallout()
+							return
+						}
+						// else if (this.settings.renderMode === 'textarea'
+						// 	|| this.settings.renderMode === 'pre'
+						// 	|| this.settings.renderMode === 'editablePre')
+						{
+							const editableCodeblock = new EditableCodeblock(this, language, source, el, ctx)
+							editableCodeblock.render()
+							return
+						}
+						// else {
+						// 	const codeBlock = new CodeBlock(this, el, source, language, ctx);
+						// 	ctx.addChild(codeBlock)
+						// 	return
+						// }
+					},
+					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..b7f29e0 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/general/EditableCodeblockInOb'
+
+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,26 @@ 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'
+							|| this.settings.renderMode === 'pre'
+							|| this.settings.renderMode === 'editablePre')
+						{
+							const editableCodeblock = new EditableCodeblock(this, language, source, el, ctx)
+							editableCodeblock.render()
+							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/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"]