feat(markdown): render TikZ fenced blocks to SVG
Adds a markdown-it extractor for ```tikz blocks that emits a sanitized placeholder, plus a runTikz() that lazy-loads the obsidian-tikzjax 0.5.2 bundle from jsdelivr on first encounter, sanitizes the produced SVG with DOMPurify, and caches it in PouchDB keyed by sha256(source) so repeat views are instant and offline-capable.
This commit is contained in:
@@ -37,6 +37,7 @@
|
||||
"arktype": "^2.1.29",
|
||||
"comlink": "^4.4.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.4.3",
|
||||
"events": "^3.3.0",
|
||||
"font-color-contrast": "^11.1.0",
|
||||
"fontfaceobserver": "^2.3.0",
|
||||
|
||||
186
pnpm-lock.yaml
generated
186
pnpm-lock.yaml
generated
@@ -68,6 +68,9 @@ importers:
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
dompurify:
|
||||
specifier: ^3.4.3
|
||||
version: 3.4.3
|
||||
events:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
@@ -200,7 +203,7 @@ importers:
|
||||
version: 12.1.1(eslint@8.57.1)
|
||||
eslint-plugin-unused-imports:
|
||||
specifier: ^4.4.1
|
||||
version: 4.4.1(@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)
|
||||
version: 4.4.1(eslint@8.57.1)
|
||||
esno:
|
||||
specifier: ^4.8.0
|
||||
version: 4.8.0
|
||||
@@ -2337,97 +2340,39 @@ packages:
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.46.2':
|
||||
resolution: {integrity: sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': ^8.46.2
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/parser@8.46.2':
|
||||
resolution: {integrity: sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/project-service@8.38.0':
|
||||
resolution: {integrity: sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <5.9.0'
|
||||
|
||||
'@typescript-eslint/project-service@8.46.2':
|
||||
resolution: {integrity: sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/scope-manager@8.38.0':
|
||||
resolution: {integrity: sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/scope-manager@8.46.2':
|
||||
resolution: {integrity: sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.38.0':
|
||||
resolution: {integrity: sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <5.9.0'
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.46.2':
|
||||
resolution: {integrity: sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/type-utils@8.46.2':
|
||||
resolution: {integrity: sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/types@8.38.0':
|
||||
resolution: {integrity: sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/types@8.46.2':
|
||||
resolution: {integrity: sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.38.0':
|
||||
resolution: {integrity: sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <5.9.0'
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.46.2':
|
||||
resolution: {integrity: sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/utils@8.46.2':
|
||||
resolution: {integrity: sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.38.0':
|
||||
resolution: {integrity: sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.46.2':
|
||||
resolution: {integrity: sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@ungap/structured-clone@1.2.0':
|
||||
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
|
||||
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
|
||||
|
||||
'@vite-pwa/assets-generator@1.0.2':
|
||||
resolution: {integrity: sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==}
|
||||
@@ -3447,6 +3392,9 @@ packages:
|
||||
dompurify@3.2.6:
|
||||
resolution: {integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==}
|
||||
|
||||
dompurify@3.4.3:
|
||||
resolution: {integrity: sha512-VVwJidIJcp1hpg2OMXML3ZVRPYSZiq4aX7qBh83BSIpOaRDqI+qxhXjjIWnpzkOXhmp0L81lnoME1mnCc9H48A==}
|
||||
|
||||
domutils@3.1.0:
|
||||
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
|
||||
|
||||
@@ -4067,10 +4015,6 @@ packages:
|
||||
resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
ignore@7.0.4:
|
||||
resolution: {integrity: sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
immutable@5.1.5:
|
||||
resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==}
|
||||
|
||||
@@ -8688,37 +8632,6 @@ snapshots:
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.1
|
||||
'@typescript-eslint/parser': 8.46.2(eslint@8.57.1)(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.46.2
|
||||
'@typescript-eslint/type-utils': 8.46.2(eslint@8.57.1)(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.46.2(eslint@8.57.1)(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.46.2
|
||||
eslint: 8.57.1
|
||||
graphemer: 1.4.0
|
||||
ignore: 7.0.4
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.1.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.46.2
|
||||
'@typescript-eslint/types': 8.46.2
|
||||
'@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.46.2
|
||||
debug: 4.4.3
|
||||
eslint: 8.57.1
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/project-service@8.38.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.9.3)
|
||||
@@ -8728,54 +8641,17 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.46.2(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.46.2
|
||||
debug: 4.4.3
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/scope-manager@8.38.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.38.0
|
||||
'@typescript-eslint/visitor-keys': 8.38.0
|
||||
|
||||
'@typescript-eslint/scope-manager@8.46.2':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.46.2
|
||||
'@typescript-eslint/visitor-keys': 8.46.2
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.38.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.46.2(typescript@5.9.3)':
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/type-utils@8.46.2(eslint@8.57.1)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.46.2
|
||||
'@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.46.2(eslint@8.57.1)(typescript@5.9.3)
|
||||
debug: 4.4.3
|
||||
eslint: 8.57.1
|
||||
ts-api-utils: 2.1.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/types@8.38.0': {}
|
||||
|
||||
'@typescript-eslint/types@8.46.2':
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.38.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.38.0(typescript@5.9.3)
|
||||
@@ -8792,46 +8668,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.46.2(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.46.2(typescript@5.9.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.46.2
|
||||
'@typescript-eslint/visitor-keys': 8.46.2
|
||||
debug: 4.4.3
|
||||
fast-glob: 3.3.3
|
||||
is-glob: 4.0.3
|
||||
minimatch: 9.0.5
|
||||
semver: 7.6.3
|
||||
ts-api-utils: 2.1.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/utils@8.46.2(eslint@8.57.1)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.7.0(eslint@8.57.1)
|
||||
'@typescript-eslint/scope-manager': 8.46.2
|
||||
'@typescript-eslint/types': 8.46.2
|
||||
'@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3)
|
||||
eslint: 8.57.1
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.38.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.38.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.46.2':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.46.2
|
||||
eslint-visitor-keys: 4.2.1
|
||||
optional: true
|
||||
|
||||
'@ungap/structured-clone@1.2.0': {}
|
||||
|
||||
'@vite-pwa/assets-generator@1.0.2':
|
||||
@@ -10005,6 +9846,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
dompurify@3.4.3:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
domutils@3.1.0:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
@@ -10192,11 +10037,9 @@ snapshots:
|
||||
dependencies:
|
||||
eslint: 8.57.1
|
||||
|
||||
eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1):
|
||||
eslint-plugin-unused-imports@4.4.1(eslint@8.57.1):
|
||||
dependencies:
|
||||
eslint: 8.57.1
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.46.2(@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
|
||||
|
||||
eslint-scope@7.2.2:
|
||||
dependencies:
|
||||
@@ -10768,9 +10611,6 @@ snapshots:
|
||||
|
||||
ignore@5.2.4: {}
|
||||
|
||||
ignore@7.0.4:
|
||||
optional: true
|
||||
|
||||
immutable@5.1.5: {}
|
||||
|
||||
import-fresh@3.3.0:
|
||||
|
||||
@@ -7,7 +7,7 @@ import SkeletonLoader from "@/components/SkeletonLoader.vue"
|
||||
import StackedNote from "@/components/StackedNote.vue"
|
||||
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||
import { useLinks } from "@/hooks/useLinks.hook"
|
||||
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||
import { markdownBuilder, runTikz } from "@/hooks/useMarkdown.hook"
|
||||
import { useNoteView } from "@/hooks/useNoteView.hook"
|
||||
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
|
||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||
@@ -64,6 +64,7 @@ watch(
|
||||
async () => {
|
||||
await nextTick()
|
||||
listenToClick()
|
||||
void runTikz(".note-display .tikz")
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useLinks } from "@/hooks/useLinks.hook"
|
||||
import {
|
||||
renderCodeFile,
|
||||
runMermaid,
|
||||
runTikz,
|
||||
useShikiji
|
||||
} from "@/hooks/useMarkdown.hook"
|
||||
import { useNoteFreshness } from "@/hooks/useNoteFreshness.hook"
|
||||
@@ -160,6 +161,10 @@ watch([content, mode], () => {
|
||||
runMermaid(`.note-${sha.value} .mermaid`)
|
||||
}
|
||||
|
||||
if (rawContent.value.includes("```tikz")) {
|
||||
void runTikz(`.note-${sha.value} .tikz`)
|
||||
}
|
||||
|
||||
if (isMarkdown.value && rawContent.value.includes("```")) {
|
||||
useShikiji()
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRoute } from "vue-router"
|
||||
|
||||
import SkeletonLoader from "@/components/SkeletonLoader.vue"
|
||||
import { useATProtoLinks } from "@/hooks/useATProtoLinks.hook"
|
||||
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||
import { markdownBuilder, runTikz } from "@/hooks/useMarkdown.hook"
|
||||
import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook"
|
||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||
import { getAuthor } from "@/modules/atproto/getAuthor"
|
||||
@@ -81,6 +81,7 @@ watch(
|
||||
async () => {
|
||||
await nextTick()
|
||||
listenToClick()
|
||||
void runTikz(`.note-${classNameId.value} .tikz`)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
@@ -7,5 +7,6 @@ export enum DataType {
|
||||
RepetitionCard = "RepetitionCard",
|
||||
History = "History",
|
||||
UserSettings = "UserSettings",
|
||||
AtprotoSession = "AtprotoSession"
|
||||
AtprotoSession = "AtprotoSession",
|
||||
TikzCache = "TikzCache"
|
||||
}
|
||||
|
||||
6
src/data/models/TikzCache.ts
Normal file
6
src/data/models/TikzCache.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
import { Model } from "@/data/models/Model"
|
||||
|
||||
export interface TikzCache extends Model<DataType.TikzCache> {
|
||||
svg: string
|
||||
}
|
||||
@@ -15,11 +15,21 @@ import mermaid from "mermaid"
|
||||
import type { LanguageRegistration } from "shikiji-core"
|
||||
import { Ref, toValue } from "vue"
|
||||
|
||||
import { data } from "@/data/data"
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
import type { TikzCache } from "@/data/models/TikzCache"
|
||||
import alloyGrammar from "@/utils/alloy.tmLanguage.json"
|
||||
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
|
||||
import {
|
||||
decodeBase64ToUTF8,
|
||||
encodeUTF8ToBase64
|
||||
} from "@/utils/decodeBase64ToUTF8"
|
||||
import { html5Media } from "@/utils/markdown/markdown-html5-media"
|
||||
import { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons"
|
||||
|
||||
const TIKZ_BUNDLE_URL =
|
||||
"https://cdn.jsdelivr.net/gh/artisticat1/obsidian-tikzjax@0.5.2/tikzjax.js"
|
||||
const TIKZ_RENDER_TIMEOUT_MS = 30000
|
||||
|
||||
const markdownItMermaidExtractor = (md: MarkdownIt) => {
|
||||
const defaultFence =
|
||||
md.renderer.rules.fence ||
|
||||
@@ -51,6 +61,37 @@ const markdownItMermaidExtractor = (md: MarkdownIt) => {
|
||||
}
|
||||
}
|
||||
|
||||
const markdownItTikzExtractor = (md: MarkdownIt) => {
|
||||
const defaultFence =
|
||||
md.renderer.rules.fence ||
|
||||
function (
|
||||
tokens: Array<Token>,
|
||||
index: number,
|
||||
options: Options,
|
||||
_: unknown,
|
||||
self: Renderer
|
||||
) {
|
||||
return self.renderToken(tokens, index, options)
|
||||
}
|
||||
|
||||
md.renderer.rules.fence = function (
|
||||
tokens: Array<Token>,
|
||||
index: number,
|
||||
options: Options,
|
||||
env: unknown,
|
||||
self: Renderer
|
||||
) {
|
||||
const token = tokens[index]
|
||||
|
||||
if (token.info.trim() === "tikz") {
|
||||
const encoded = encodeUTF8ToBase64(token.content)
|
||||
return `<pre class="tikz" data-tikz-source="${encoded}"><span class="tikz-loading">Rendering TikZ…</span></pre>\n`
|
||||
}
|
||||
|
||||
return defaultFence(tokens, index, options, env, self)
|
||||
}
|
||||
}
|
||||
|
||||
const slugger = new GithubSlugger()
|
||||
|
||||
let tabGroupCounter = 0
|
||||
@@ -62,6 +103,7 @@ const md = new MarkdownIt({
|
||||
quotes: ["«\xA0", "\xA0»", "‹\xA0", "\xA0›"]
|
||||
})
|
||||
.use(markdownItMermaidExtractor)
|
||||
.use(markdownItTikzExtractor)
|
||||
.use(html5Media)
|
||||
.use(blockEmbedPlugin, {
|
||||
youtube: {
|
||||
@@ -145,6 +187,165 @@ export const runMermaid = (querySelector: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
let tikzBundlePromise: Promise<void> | null = null
|
||||
let domPurifyPromise: Promise<typeof import("dompurify")> | null = null
|
||||
|
||||
const ensureTikzBundle = (): Promise<void> => {
|
||||
if (tikzBundlePromise) return tikzBundlePromise
|
||||
|
||||
tikzBundlePromise = new Promise<void>((resolve, reject) => {
|
||||
const existing = document.getElementById("tikzjax-bundle")
|
||||
if (existing) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const script = document.createElement("script")
|
||||
script.id = "tikzjax-bundle"
|
||||
script.src = TIKZ_BUNDLE_URL
|
||||
script.async = true
|
||||
script.crossOrigin = "anonymous"
|
||||
script.addEventListener("load", () => resolve())
|
||||
script.addEventListener("error", () => {
|
||||
tikzBundlePromise = null
|
||||
reject(new Error("Failed to load TikZ engine"))
|
||||
})
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
return tikzBundlePromise
|
||||
}
|
||||
|
||||
const ensureDomPurify = (): Promise<typeof import("dompurify")> => {
|
||||
if (!domPurifyPromise) domPurifyPromise = import("dompurify")
|
||||
return domPurifyPromise
|
||||
}
|
||||
|
||||
const sha256Hex = async (text: string): Promise<string> => {
|
||||
const buf = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(text)
|
||||
)
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
}
|
||||
|
||||
const sanitizeSvg = async (svg: string): Promise<string> => {
|
||||
const DOMPurify = (await ensureDomPurify()).default
|
||||
return DOMPurify.sanitize(svg, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true }
|
||||
})
|
||||
}
|
||||
|
||||
const tidyTikzSource = (s: string): string =>
|
||||
s
|
||||
.replaceAll(" ", "")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line)
|
||||
.join("\n")
|
||||
|
||||
const renderTikzError = (err: unknown, source: string): string => {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
return (
|
||||
`<div class="tikz-error"><strong>TikZ error</strong>` +
|
||||
`<pre>${md.utils.escapeHtml(msg)}</pre>` +
|
||||
`<details><summary>source</summary>` +
|
||||
`<pre>${md.utils.escapeHtml(source)}</pre></details></div>`
|
||||
)
|
||||
}
|
||||
|
||||
const renderOneTikzBlock = (
|
||||
el: HTMLElement,
|
||||
source: string
|
||||
): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
const timer = window.setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
el.removeEventListener("tikzjax-load-finished", handler)
|
||||
reject(new Error("TikZ render timed out"))
|
||||
}, TIKZ_RENDER_TIMEOUT_MS)
|
||||
|
||||
const handler = (e: Event) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
window.clearTimeout(timer)
|
||||
el.removeEventListener("tikzjax-load-finished", handler)
|
||||
const target = e.target as Element | null
|
||||
if (!target) {
|
||||
reject(new Error("TikZ produced no output"))
|
||||
return
|
||||
}
|
||||
resolve(target.outerHTML)
|
||||
}
|
||||
|
||||
el.addEventListener("tikzjax-load-finished", handler)
|
||||
|
||||
while (el.firstChild) el.removeChild(el.firstChild)
|
||||
const script = document.createElement("script")
|
||||
script.type = "text/tikz"
|
||||
script.textContent = source
|
||||
el.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
export const runTikz = async (querySelector: string): Promise<void> => {
|
||||
const elements = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(querySelector)
|
||||
)
|
||||
if (elements.length === 0) return
|
||||
|
||||
await Promise.all(
|
||||
elements.map(async (el) => {
|
||||
if (el.dataset.tikzRendered) return
|
||||
el.dataset.tikzRendered = "pending"
|
||||
|
||||
const encoded = el.dataset.tikzSource
|
||||
if (!encoded) {
|
||||
el.dataset.tikzRendered = "error"
|
||||
return
|
||||
}
|
||||
|
||||
const source = tidyTikzSource(decodeBase64ToUTF8(encoded))
|
||||
|
||||
let hash: string
|
||||
try {
|
||||
hash = await sha256Hex(source)
|
||||
} catch (err) {
|
||||
el.innerHTML = renderTikzError(err, source)
|
||||
el.dataset.tikzRendered = "error"
|
||||
return
|
||||
}
|
||||
|
||||
const cacheId = `${DataType.TikzCache}-${hash}`
|
||||
const cached = await data.get<DataType.TikzCache, TikzCache>(cacheId)
|
||||
if (cached?.svg) {
|
||||
el.innerHTML = cached.svg
|
||||
el.dataset.tikzRendered = "true"
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureTikzBundle()
|
||||
const rawSvg = await renderOneTikzBlock(el, source)
|
||||
const sanitized = await sanitizeSvg(rawSvg)
|
||||
el.innerHTML = sanitized
|
||||
el.dataset.tikzRendered = "true"
|
||||
void data.add<DataType.TikzCache>({
|
||||
_id: cacheId,
|
||||
$type: DataType.TikzCache,
|
||||
svg: sanitized
|
||||
} as TikzCache)
|
||||
} catch (err) {
|
||||
el.innerHTML = renderTikzError(err, source)
|
||||
el.dataset.tikzRendered = "error"
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const rules: RenderRuleRecord = {
|
||||
table_open: () =>
|
||||
'<div class="overflow-x-auto"><table class="table table-zebra">',
|
||||
|
||||
@@ -188,6 +188,36 @@ pre.mermaid {
|
||||
background-color: var(--color-base-content);
|
||||
}
|
||||
|
||||
pre.tikz {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
pre.tikz svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.tikz-loading {
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tikz-error {
|
||||
border-left: 3px solid var(--color-error);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tikz-error pre {
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.markdown-alert {
|
||||
padding: 1rem;
|
||||
background-color: #ecf0f1;
|
||||
|
||||
@@ -9,7 +9,7 @@ import SkeletonLoader from "@/components/SkeletonLoader.vue"
|
||||
import StackedPublicNote from "@/components/StackedPublicNote.vue"
|
||||
import ThemeSwap from "@/components/ThemeSwap.vue"
|
||||
import { useATProtoLinks } from "@/hooks/useATProtoLinks.hook"
|
||||
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||
import { markdownBuilder, runTikz } from "@/hooks/useMarkdown.hook"
|
||||
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
|
||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||
import { getAuthor } from "@/modules/atproto/getAuthor"
|
||||
@@ -120,6 +120,7 @@ watch(
|
||||
async () => {
|
||||
await nextTick()
|
||||
listenToClick()
|
||||
void runTikz(".public-note-view .note-display .tikz")
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user