mirror of
https://github.com/mntno/obsidian-come-down.git
synced 2026-07-22 05:43:15 +00:00
1.1.0
This commit is contained in:
parent
66272ab777
commit
fa5323f538
28 changed files with 1781 additions and 811 deletions
|
|
@ -1,10 +1,20 @@
|
|||
{
|
||||
"autoAccept": false,
|
||||
"contextFileName": ["GEMINI.md"],
|
||||
"fileFiltering": {
|
||||
"respectGitIgnore": true,
|
||||
"enableRecursiveFileSearch": true
|
||||
},
|
||||
"hideBanner": true,
|
||||
"usageStatisticsEnabled": false
|
||||
"tools": {
|
||||
"autoAccept": false
|
||||
},
|
||||
"context": {
|
||||
"fileName": [
|
||||
"AGENTS.md"
|
||||
],
|
||||
"fileFiltering": {
|
||||
"respectGitIgnore": true,
|
||||
"enableRecursiveFileSearch": true
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"hideBanner": true
|
||||
},
|
||||
"privacy": {
|
||||
"usageStatisticsEnabled": false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -23,4 +23,3 @@ main.js
|
|||
|
||||
## Created by plugin.
|
||||
cache.json
|
||||
cache/
|
||||
|
|
|
|||
5
.zed/settings.json
Normal file
5
.zed/settings.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Folder-specific settings
|
||||
//
|
||||
// For a full list of overridable settings, and general information on folder-specific settings,
|
||||
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
|
||||
{}
|
||||
137
AGENTS.md
Normal file
137
AGENTS.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Obsidian community plugin
|
||||
|
||||
## Project overview
|
||||
|
||||
- Target: Obsidian Community Plugin (TypeScript → bundled JavaScript).
|
||||
- Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian.
|
||||
- Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`.
|
||||
|
||||
## Environment & tooling
|
||||
|
||||
- Node.js: use current LTS (Node 18+ recommended).
|
||||
- **Package manager: pnpm** (required for this sample - `package.json` defines pnpm scripts and dependencies).
|
||||
- **Bundler: esbuild** (required for this sample - `esbuild.config.mjs` and build scripts depend on it). Alternative bundlers like Rollup or webpack are acceptable for other projects if they bundle all external dependencies into `main.js`.
|
||||
- Types: `obsidian` type definitions.
|
||||
|
||||
**Note**: This sample project has specific technical dependencies on pnpm and esbuild. If you're creating a plugin from scratch, you can choose different tools, but you'll need to replace the build configuration accordingly.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Dev (watch)
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
### Production build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Manifest rules (`manifest.json`)
|
||||
|
||||
- Must include (non-exhaustive):
|
||||
- `id` (plugin ID; for local dev it should match the folder name)
|
||||
- `name`
|
||||
- `version` (Semantic Versioning `x.y.z`)
|
||||
- `minAppVersion`
|
||||
- `description`
|
||||
- `isDesktopOnly` (boolean)
|
||||
- Optional: `author`, `authorUrl`, `fundingUrl` (string or map)
|
||||
- Never change `id` after release. Treat it as stable API.
|
||||
- Keep `minAppVersion` accurate when using newer APIs.
|
||||
- Canonical requirements are coded here: https://github.com/obsidianmd/obsidian-releases/blob/master/.github/workflows/validate-plugin-entry.yml
|
||||
|
||||
|
||||
## Versioning & releases
|
||||
|
||||
- Bump `version` in `manifest.json` (SemVer) and update `versions.json` to map plugin version → minimum app version.
|
||||
- Create a GitHub release whose tag exactly matches `manifest.json`'s `version`. Do not use a leading `v`.
|
||||
- Attach `manifest.json`, `main.js`, and `styles.css` (if present) to the release as individual assets.
|
||||
- After the initial release, follow the process to add/update your plugin in the community catalog as required.
|
||||
|
||||
## Security, privacy, and compliance
|
||||
|
||||
Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular:
|
||||
|
||||
- Default to local/offline operation. Only make network requests when essential to the feature.
|
||||
- No hidden telemetry. If you collect optional analytics or call third-party services, require explicit opt-in and document clearly in `README.md` and in settings.
|
||||
- Never execute remote code, fetch and eval scripts, or auto-update plugin code outside of normal releases.
|
||||
- Minimize scope: read/write only what's necessary inside the vault. Do not access files outside the vault.
|
||||
- Clearly disclose any external services used, data sent, and risks.
|
||||
- Respect user privacy. Do not collect vault contents, filenames, or personal information unless absolutely necessary and explicitly consented.
|
||||
- Avoid deceptive patterns, ads, or spammy notifications.
|
||||
- Register and clean up all DOM, app, and interval listeners using the provided `register*` helpers so the plugin unloads safely.
|
||||
|
||||
## UX & copy guidelines (for UI text, commands, settings)
|
||||
|
||||
- Prefer sentence case for headings, buttons, and titles.
|
||||
- Use clear, action-oriented imperatives in step-by-step copy.
|
||||
- Use **bold** to indicate literal UI labels. Prefer "select" for interactions.
|
||||
- Use arrow notation for navigation: **Settings → Community plugins**.
|
||||
- Keep in-app strings short, consistent, and free of jargon.
|
||||
|
||||
## Performance
|
||||
|
||||
- Keep startup light. Defer heavy work until needed.
|
||||
- Avoid long-running tasks during `onload`; use lazy initialization.
|
||||
- Batch disk access and avoid excessive vault scans.
|
||||
- Debounce/throttle expensive operations in response to file system events.
|
||||
|
||||
## Coding conventions
|
||||
|
||||
- TypeScript with `"strict": true` preferred.
|
||||
- **Keep `main.ts` minimal**: Focus only on plugin lifecycle (onload, onunload, addCommand calls). Delegate all feature logic to separate modules.
|
||||
- **Split large files**: If any file exceeds ~200-300 lines, consider breaking it into smaller, focused modules.
|
||||
- **Use clear module boundaries**: Each file should have a single, well-defined responsibility.
|
||||
- Bundle everything into `main.js` (no unbundled runtime deps).
|
||||
- Avoid Node/Electron APIs if you want mobile compatibility; set `isDesktopOnly` accordingly.
|
||||
- Prefer `async/await` over promise chains; handle errors gracefully.
|
||||
|
||||
## Mobile
|
||||
|
||||
- Where feasible, test on iOS and Android.
|
||||
- Don't assume desktop-only behavior unless `isDesktopOnly` is `true`.
|
||||
- Avoid large in-memory structures; be mindful of memory and storage constraints.
|
||||
|
||||
## Agent do/don't
|
||||
|
||||
**Do**
|
||||
- Write idempotent code paths so reload/unload doesn't leak listeners or intervals.
|
||||
- Use `this.register*` helpers for everything that needs cleanup.
|
||||
|
||||
**Don't**
|
||||
- Introduce network calls without an obvious user-facing reason and documentation.
|
||||
- Ship features that require cloud services without clear disclosure and explicit opt-in.
|
||||
- Store or transmit vault contents unless essential and consented.
|
||||
|
||||
## Common tasks
|
||||
|
||||
### Register listeners safely
|
||||
|
||||
```ts
|
||||
this.registerEvent(this.app.workspace.on("file-open", f => { /* ... */ }));
|
||||
this.registerDomEvent(window, "resize", () => { /* ... */ });
|
||||
this.registerInterval(window.setInterval(() => { /* ... */ }, 1000));
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `<Vault>/.obsidian/plugins/<plugin-id>/`.
|
||||
- Build issues: if `main.js` is missing, run `pnpm run build` or `pnpm run dev` to compile your TypeScript source code.
|
||||
- Commands not appearing: verify `addCommand` runs after `onload` and IDs are unique.
|
||||
- Settings not persisting: ensure `loadData`/`saveData` are awaited and you re-render the UI after changes.
|
||||
- Mobile-only issues: confirm you're not using desktop-only APIs; check `isDesktopOnly` and adjust.
|
||||
|
||||
## References
|
||||
|
||||
- Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin
|
||||
- API documentation: https://docs.obsidian.md
|
||||
- Developer policies: https://docs.obsidian.md/Developer+policies
|
||||
- Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines
|
||||
- Style guide: https://help.obsidian.md/style-guide
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "come-down",
|
||||
"name": "Come Down",
|
||||
"version": "1.0.7",
|
||||
"version": "1.1.0",
|
||||
"minAppVersion": "1.8.0",
|
||||
"description": "Maintains a cache of your notes’ embedded external images.",
|
||||
"author": "mntno",
|
||||
|
|
|
|||
15
package.json
15
package.json
|
|
@ -13,20 +13,21 @@
|
|||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@codemirror/language": "6.11.3",
|
||||
"@codemirror/view": "^6.38.2",
|
||||
"@eslint/js": "9.35.0",
|
||||
"@codemirror/state": "6.5.0",
|
||||
"@codemirror/view": "6.38.1",
|
||||
"@eslint/js": "9.37.0",
|
||||
"@lezer/common": "^1.2.3",
|
||||
"@types/node": "24.5.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.44.0",
|
||||
"@typescript-eslint/parser": "8.44.0",
|
||||
"@types/node": "24.6.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.45.0",
|
||||
"@typescript-eslint/parser": "8.45.0",
|
||||
"builtin-modules": "5.0.0",
|
||||
"esbuild": "0.25.10",
|
||||
"globals": "16.4.0",
|
||||
"image-size": "^2.0.2",
|
||||
"obsidian": "latest",
|
||||
"tslib": "2.8.1",
|
||||
"typescript": "5.9.2",
|
||||
"typescript-eslint": "8.44.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.45.0",
|
||||
"xxhash-wasm": "1.1.0"
|
||||
},
|
||||
"type": "module",
|
||||
|
|
|
|||
241
pnpm-lock.yaml
241
pnpm-lock.yaml
|
|
@ -11,24 +11,27 @@ importers:
|
|||
'@codemirror/language':
|
||||
specifier: 6.11.3
|
||||
version: 6.11.3
|
||||
'@codemirror/state':
|
||||
specifier: 6.5.0
|
||||
version: 6.5.0
|
||||
'@codemirror/view':
|
||||
specifier: ^6.38.2
|
||||
version: 6.38.2
|
||||
specifier: 6.38.1
|
||||
version: 6.38.1
|
||||
'@eslint/js':
|
||||
specifier: 9.35.0
|
||||
version: 9.35.0
|
||||
specifier: 9.37.0
|
||||
version: 9.37.0
|
||||
'@lezer/common':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3
|
||||
'@types/node':
|
||||
specifier: 24.5.2
|
||||
version: 24.5.2
|
||||
specifier: 24.6.2
|
||||
version: 24.6.2
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: 8.44.0
|
||||
version: 8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.21.0)(typescript@5.9.2))(eslint@9.21.0)(typescript@5.9.2)
|
||||
specifier: 8.45.0
|
||||
version: 8.45.0(@typescript-eslint/parser@8.45.0(eslint@9.21.0)(typescript@5.9.3))(eslint@9.21.0)(typescript@5.9.3)
|
||||
'@typescript-eslint/parser':
|
||||
specifier: 8.44.0
|
||||
version: 8.44.0(eslint@9.21.0)(typescript@5.9.2)
|
||||
specifier: 8.45.0
|
||||
version: 8.45.0(eslint@9.21.0)(typescript@5.9.3)
|
||||
builtin-modules:
|
||||
specifier: 5.0.0
|
||||
version: 5.0.0
|
||||
|
|
@ -43,16 +46,16 @@ importers:
|
|||
version: 2.0.2
|
||||
obsidian:
|
||||
specifier: latest
|
||||
version: 1.8.7(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)
|
||||
version: 1.10.0(@codemirror/state@6.5.0)(@codemirror/view@6.38.1)
|
||||
tslib:
|
||||
specifier: 2.8.1
|
||||
version: 2.8.1
|
||||
typescript:
|
||||
specifier: 5.9.2
|
||||
version: 5.9.2
|
||||
specifier: 5.9.3
|
||||
version: 5.9.3
|
||||
typescript-eslint:
|
||||
specifier: 8.44.0
|
||||
version: 8.44.0(eslint@9.21.0)(typescript@5.9.2)
|
||||
specifier: 8.45.0
|
||||
version: 8.45.0(eslint@9.21.0)(typescript@5.9.3)
|
||||
xxhash-wasm:
|
||||
specifier: 1.1.0
|
||||
version: 1.1.0
|
||||
|
|
@ -62,11 +65,11 @@ packages:
|
|||
'@codemirror/language@6.11.3':
|
||||
resolution: {integrity: sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==}
|
||||
|
||||
'@codemirror/state@6.5.2':
|
||||
resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==}
|
||||
'@codemirror/state@6.5.0':
|
||||
resolution: {integrity: sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==}
|
||||
|
||||
'@codemirror/view@6.38.2':
|
||||
resolution: {integrity: sha512-bTWAJxL6EOFLPzTx+O5P5xAO3gTqpatQ2b/ARQ8itfU/v2LlpS3pH2fkL0A3E/Fx8Y2St2KES7ZEV0sHTsSW/A==}
|
||||
'@codemirror/view@6.38.1':
|
||||
resolution: {integrity: sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.10':
|
||||
resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==}
|
||||
|
|
@ -254,8 +257,8 @@ packages:
|
|||
resolution: {integrity: sha512-BqStZ3HX8Yz6LvsF5ByXYrtigrV5AXADWLAGc7PH/1SxOb7/FIYYMszZZWiUou/GB9P2lXWk2SV4d+Z8h0nknw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/js@9.35.0':
|
||||
resolution: {integrity: sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==}
|
||||
'@eslint/js@9.37.0':
|
||||
resolution: {integrity: sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/object-schema@2.1.6':
|
||||
|
|
@ -315,69 +318,69 @@ packages:
|
|||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/node@24.5.2':
|
||||
resolution: {integrity: sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==}
|
||||
'@types/node@24.6.2':
|
||||
resolution: {integrity: sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang==}
|
||||
|
||||
'@types/tern@0.23.9':
|
||||
resolution: {integrity: sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.44.0':
|
||||
resolution: {integrity: sha512-EGDAOGX+uwwekcS0iyxVDmRV9HX6FLSM5kzrAToLTsr9OWCIKG/y3lQheCq18yZ5Xh78rRKJiEpP0ZaCs4ryOQ==}
|
||||
'@typescript-eslint/eslint-plugin@8.45.0':
|
||||
resolution: {integrity: sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': ^8.44.0
|
||||
'@typescript-eslint/parser': ^8.45.0
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/parser@8.44.0':
|
||||
resolution: {integrity: sha512-VGMpFQGUQWYT9LfnPcX8ouFojyrZ/2w3K5BucvxL/spdNehccKhB4jUyB1yBCXpr2XFm0jkECxgrpXBW2ipoAw==}
|
||||
'@typescript-eslint/parser@8.45.0':
|
||||
resolution: {integrity: sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==}
|
||||
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.44.0':
|
||||
resolution: {integrity: sha512-ZeaGNraRsq10GuEohKTo4295Z/SuGcSq2LzfGlqiuEvfArzo/VRrT0ZaJsVPuKZ55lVbNk8U6FcL+ZMH8CoyVA==}
|
||||
'@typescript-eslint/project-service@8.45.0':
|
||||
resolution: {integrity: sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/scope-manager@8.44.0':
|
||||
resolution: {integrity: sha512-87Jv3E+al8wpD+rIdVJm/ItDBe/Im09zXIjFoipOjr5gHUhJmTzfFLuTJ/nPTMc2Srsroy4IBXwcTCHyRR7KzA==}
|
||||
'@typescript-eslint/scope-manager@8.45.0':
|
||||
resolution: {integrity: sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.44.0':
|
||||
resolution: {integrity: sha512-x5Y0+AuEPqAInc6yd0n5DAcvtoQ/vyaGwuX5HE9n6qAefk1GaedqrLQF8kQGylLUb9pnZyLf+iEiL9fr8APDtQ==}
|
||||
'@typescript-eslint/tsconfig-utils@8.45.0':
|
||||
resolution: {integrity: sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/type-utils@8.44.0':
|
||||
resolution: {integrity: sha512-9cwsoSxJ8Sak67Be/hD2RNt/fsqmWnNE1iHohG8lxqLSNY8xNfyY7wloo5zpW3Nu9hxVgURevqfcH6vvKCt6yg==}
|
||||
'@typescript-eslint/type-utils@8.45.0':
|
||||
resolution: {integrity: sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A==}
|
||||
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.44.0':
|
||||
resolution: {integrity: sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA==}
|
||||
'@typescript-eslint/types@8.45.0':
|
||||
resolution: {integrity: sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.44.0':
|
||||
resolution: {integrity: sha512-lqNj6SgnGcQZwL4/SBJ3xdPEfcBuhCG8zdcwCPgYcmiPLgokiNDKlbPzCwEwu7m279J/lBYWtDYL+87OEfn8Jw==}
|
||||
'@typescript-eslint/typescript-estree@8.45.0':
|
||||
resolution: {integrity: sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/utils@8.44.0':
|
||||
resolution: {integrity: sha512-nktOlVcg3ALo0mYlV+L7sWUD58KG4CMj1rb2HUVOO4aL3K/6wcD+NERqd0rrA5Vg06b42YhF6cFxeixsp9Riqg==}
|
||||
'@typescript-eslint/utils@8.45.0':
|
||||
resolution: {integrity: sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg==}
|
||||
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.44.0':
|
||||
resolution: {integrity: sha512-zaz9u8EJ4GBmnehlrpoKvj/E3dNbuQ7q0ucyZImm3cLqJ8INTc970B1qEqDX/Rzq65r3TvVTN7kHWPBoyW7DWw==}
|
||||
'@typescript-eslint/visitor-keys@8.45.0':
|
||||
resolution: {integrity: sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
acorn-jsx@5.3.2:
|
||||
|
|
@ -650,11 +653,11 @@ packages:
|
|||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
obsidian@1.8.7:
|
||||
resolution: {integrity: sha512-h4bWwNFAGRXlMlMAzdEiIM2ppTGlrh7uGOJS6w4gClrsjc+ei/3YAtU2VdFUlCiPuTHpY4aBpFJJW75S1Tl/JA==}
|
||||
obsidian@1.10.0:
|
||||
resolution: {integrity: sha512-F7hhnmGRQD1TanDPFT//LD3iKNUVd7N8sKL7flCCHRszfTxpDJ39j3T7LHbcGpyid906i6lD5oO+cnfLBzJMKw==}
|
||||
peerDependencies:
|
||||
'@codemirror/state': ^6.0.0
|
||||
'@codemirror/view': ^6.0.0
|
||||
'@codemirror/state': 6.5.0
|
||||
'@codemirror/view': 6.38.1
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
|
|
@ -747,20 +750,20 @@ packages:
|
|||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
typescript-eslint@8.44.0:
|
||||
resolution: {integrity: sha512-ib7mCkYuIzYonCq9XWF5XNw+fkj2zg629PSa9KNIQ47RXFF763S5BIX4wqz1+FLPogTZoiw8KmCiRPRa8bL3qw==}
|
||||
typescript-eslint@8.45.0:
|
||||
resolution: {integrity: sha512-qzDmZw/Z5beNLUrXfd0HIW6MzIaAV5WNDxmMs9/3ojGOpYavofgNAAD/nC6tGV2PczIi0iw8vot2eAe/sBn7zg==}
|
||||
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@5.9.2:
|
||||
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@7.12.0:
|
||||
resolution: {integrity: sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==}
|
||||
undici-types@7.13.0:
|
||||
resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==}
|
||||
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
|
@ -788,20 +791,20 @@ snapshots:
|
|||
|
||||
'@codemirror/language@6.11.3':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/view': 6.38.2
|
||||
'@codemirror/state': 6.5.0
|
||||
'@codemirror/view': 6.38.1
|
||||
'@lezer/common': 1.2.3
|
||||
'@lezer/highlight': 1.2.1
|
||||
'@lezer/lr': 1.4.2
|
||||
style-mod: 4.1.2
|
||||
|
||||
'@codemirror/state@6.5.2':
|
||||
'@codemirror/state@6.5.0':
|
||||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.2
|
||||
|
||||
'@codemirror/view@6.38.2':
|
||||
'@codemirror/view@6.38.1':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/state': 6.5.0
|
||||
crelt: 1.0.6
|
||||
style-mod: 4.1.2
|
||||
w3c-keyname: 2.2.8
|
||||
|
|
@ -923,7 +926,7 @@ snapshots:
|
|||
|
||||
'@eslint/js@9.21.0': {}
|
||||
|
||||
'@eslint/js@9.35.0': {}
|
||||
'@eslint/js@9.37.0': {}
|
||||
|
||||
'@eslint/object-schema@2.1.6': {}
|
||||
|
||||
|
|
@ -975,105 +978,105 @@ snapshots:
|
|||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/node@24.5.2':
|
||||
'@types/node@24.6.2':
|
||||
dependencies:
|
||||
undici-types: 7.12.0
|
||||
undici-types: 7.13.0
|
||||
|
||||
'@types/tern@0.23.9':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.21.0)(typescript@5.9.2))(eslint@9.21.0)(typescript@5.9.2)':
|
||||
'@typescript-eslint/eslint-plugin@8.45.0(@typescript-eslint/parser@8.45.0(eslint@9.21.0)(typescript@5.9.3))(eslint@9.21.0)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.1
|
||||
'@typescript-eslint/parser': 8.44.0(eslint@9.21.0)(typescript@5.9.2)
|
||||
'@typescript-eslint/scope-manager': 8.44.0
|
||||
'@typescript-eslint/type-utils': 8.44.0(eslint@9.21.0)(typescript@5.9.2)
|
||||
'@typescript-eslint/utils': 8.44.0(eslint@9.21.0)(typescript@5.9.2)
|
||||
'@typescript-eslint/visitor-keys': 8.44.0
|
||||
'@typescript-eslint/parser': 8.45.0(eslint@9.21.0)(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.45.0
|
||||
'@typescript-eslint/type-utils': 8.45.0(eslint@9.21.0)(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.45.0(eslint@9.21.0)(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.45.0
|
||||
eslint: 9.21.0
|
||||
graphemer: 1.4.0
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.1.0(typescript@5.9.2)
|
||||
typescript: 5.9.2
|
||||
ts-api-utils: 2.1.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.44.0(eslint@9.21.0)(typescript@5.9.2)':
|
||||
'@typescript-eslint/parser@8.45.0(eslint@9.21.0)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.44.0
|
||||
'@typescript-eslint/types': 8.44.0
|
||||
'@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.2)
|
||||
'@typescript-eslint/visitor-keys': 8.44.0
|
||||
'@typescript-eslint/scope-manager': 8.45.0
|
||||
'@typescript-eslint/types': 8.45.0
|
||||
'@typescript-eslint/typescript-estree': 8.45.0(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.45.0
|
||||
debug: 4.4.3
|
||||
eslint: 9.21.0
|
||||
typescript: 5.9.2
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.44.0(typescript@5.9.2)':
|
||||
'@typescript-eslint/project-service@8.45.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.9.2)
|
||||
'@typescript-eslint/types': 8.44.0
|
||||
'@typescript-eslint/tsconfig-utils': 8.45.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.45.0
|
||||
debug: 4.4.3
|
||||
typescript: 5.9.2
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/scope-manager@8.44.0':
|
||||
'@typescript-eslint/scope-manager@8.45.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.44.0
|
||||
'@typescript-eslint/visitor-keys': 8.44.0
|
||||
'@typescript-eslint/types': 8.45.0
|
||||
'@typescript-eslint/visitor-keys': 8.45.0
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.44.0(typescript@5.9.2)':
|
||||
'@typescript-eslint/tsconfig-utils@8.45.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
typescript: 5.9.2
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.44.0(eslint@9.21.0)(typescript@5.9.2)':
|
||||
'@typescript-eslint/type-utils@8.45.0(eslint@9.21.0)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.44.0
|
||||
'@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.2)
|
||||
'@typescript-eslint/utils': 8.44.0(eslint@9.21.0)(typescript@5.9.2)
|
||||
'@typescript-eslint/types': 8.45.0
|
||||
'@typescript-eslint/typescript-estree': 8.45.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.45.0(eslint@9.21.0)(typescript@5.9.3)
|
||||
debug: 4.4.3
|
||||
eslint: 9.21.0
|
||||
ts-api-utils: 2.1.0(typescript@5.9.2)
|
||||
typescript: 5.9.2
|
||||
ts-api-utils: 2.1.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/types@8.44.0': {}
|
||||
'@typescript-eslint/types@8.45.0': {}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.44.0(typescript@5.9.2)':
|
||||
'@typescript-eslint/typescript-estree@8.45.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.44.0(typescript@5.9.2)
|
||||
'@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.9.2)
|
||||
'@typescript-eslint/types': 8.44.0
|
||||
'@typescript-eslint/visitor-keys': 8.44.0
|
||||
'@typescript-eslint/project-service': 8.45.0(typescript@5.9.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.45.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.45.0
|
||||
'@typescript-eslint/visitor-keys': 8.45.0
|
||||
debug: 4.4.3
|
||||
fast-glob: 3.3.3
|
||||
is-glob: 4.0.3
|
||||
minimatch: 9.0.5
|
||||
semver: 7.7.2
|
||||
ts-api-utils: 2.1.0(typescript@5.9.2)
|
||||
typescript: 5.9.2
|
||||
ts-api-utils: 2.1.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.44.0(eslint@9.21.0)(typescript@5.9.2)':
|
||||
'@typescript-eslint/utils@8.45.0(eslint@9.21.0)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.0(eslint@9.21.0)
|
||||
'@typescript-eslint/scope-manager': 8.44.0
|
||||
'@typescript-eslint/types': 8.44.0
|
||||
'@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.2)
|
||||
'@typescript-eslint/scope-manager': 8.45.0
|
||||
'@typescript-eslint/types': 8.45.0
|
||||
'@typescript-eslint/typescript-estree': 8.45.0(typescript@5.9.3)
|
||||
eslint: 9.21.0
|
||||
typescript: 5.9.2
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.44.0':
|
||||
'@typescript-eslint/visitor-keys@8.45.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.44.0
|
||||
'@typescript-eslint/types': 8.45.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.15.0):
|
||||
|
|
@ -1361,10 +1364,10 @@ snapshots:
|
|||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
obsidian@1.8.7(@codemirror/state@6.5.2)(@codemirror/view@6.38.2):
|
||||
obsidian@1.10.0(@codemirror/state@6.5.0)(@codemirror/view@6.38.1):
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/view': 6.38.2
|
||||
'@codemirror/state': 6.5.0
|
||||
'@codemirror/view': 6.38.1
|
||||
'@types/codemirror': 5.60.8
|
||||
moment: 2.29.4
|
||||
|
||||
|
|
@ -1429,9 +1432,9 @@ snapshots:
|
|||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
ts-api-utils@2.1.0(typescript@5.9.2):
|
||||
ts-api-utils@2.1.0(typescript@5.9.3):
|
||||
dependencies:
|
||||
typescript: 5.9.2
|
||||
typescript: 5.9.3
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
|
|
@ -1439,20 +1442,20 @@ snapshots:
|
|||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
typescript-eslint@8.44.0(eslint@9.21.0)(typescript@5.9.2):
|
||||
typescript-eslint@8.45.0(eslint@9.21.0)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.21.0)(typescript@5.9.2))(eslint@9.21.0)(typescript@5.9.2)
|
||||
'@typescript-eslint/parser': 8.44.0(eslint@9.21.0)(typescript@5.9.2)
|
||||
'@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.2)
|
||||
'@typescript-eslint/utils': 8.44.0(eslint@9.21.0)(typescript@5.9.2)
|
||||
'@typescript-eslint/eslint-plugin': 8.45.0(@typescript-eslint/parser@8.45.0(eslint@9.21.0)(typescript@5.9.3))(eslint@9.21.0)(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.45.0(eslint@9.21.0)(typescript@5.9.3)
|
||||
'@typescript-eslint/typescript-estree': 8.45.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.45.0(eslint@9.21.0)(typescript@5.9.3)
|
||||
eslint: 9.21.0
|
||||
typescript: 5.9.2
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
typescript@5.9.2: {}
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@7.12.0: {}
|
||||
undici-types@7.13.0: {}
|
||||
|
||||
uri-js@4.4.1:
|
||||
dependencies:
|
||||
|
|
|
|||
29
src/Env.ts
29
src/Env.ts
|
|
@ -22,13 +22,14 @@ export const DevContext = {
|
|||
|
||||
logCategory: {
|
||||
CACHE_MANAGER: true,
|
||||
DEBUGGING: true,
|
||||
EDIT_UPDATE_PASS: true,
|
||||
POST_PROCESS_PASS: true,
|
||||
WORKAROUNDS: false,
|
||||
} as const,
|
||||
|
||||
icon: {
|
||||
NONE: " ",
|
||||
DEBUG: "🐞",
|
||||
CACHE_MANAGER: "📦",
|
||||
EDIT_UPDATE_PASS: "✏️",
|
||||
POST_PROCESS_PASS: "📖",
|
||||
|
|
@ -47,27 +48,13 @@ export const Env = {
|
|||
log: {
|
||||
noop: () => { },
|
||||
|
||||
/** To provide very granular, low-level, and highly detailed information. These logs are often too numerous to be helpful during general development but are invaluable when you're trying to diagnose a specific, complex bug. */
|
||||
d: devLogger.debug,
|
||||
|
||||
/**
|
||||
* - This is the most general-purpose logging method. It's a good default when a message doesn't neatly fit into the error, warn, or info categories, or when you're just doing quick, ad-hoc debugging.
|
||||
* - When you use `console.log()`, you are typically saying: "Just output this general message." It's more of a catch-all, or often used for quick, ad-hoc debugging prints.
|
||||
*/
|
||||
l: devLogger.log,
|
||||
|
||||
/**
|
||||
* - To provide high-level, general information about the application's flow or significant events. These are like "milestones" that give you an overview of what the application is doing.
|
||||
* - This is an informational message about a significant event or the general flow of the application. It implies a higher level of importance or a more structured type of message than a generic `log`.
|
||||
*/
|
||||
i: devLogger.info,
|
||||
|
||||
/** To indicate a potential issue, a suboptimal practice, a deprecated feature being used, or a situation that might lead to an error later but isn't critical right now. It's a "heads up" or a "soft error." */
|
||||
w: devLogger.warn,
|
||||
|
||||
/** Always logs */
|
||||
w: console.warn,
|
||||
e: console.error,
|
||||
|
||||
debug: DevContext.logCategory.DEBUGGING ? devLogger.info : noopLogger.info,
|
||||
edit: DevContext.logCategory.EDIT_UPDATE_PASS ? devLogger.info : noopLogger.info,
|
||||
read: DevContext.logCategory.POST_PROCESS_PASS ? devLogger.info : noopLogger.info,
|
||||
cm: DevContext.logCategory.CACHE_MANAGER ? devLogger.info : noopLogger.info,
|
||||
|
|
@ -110,7 +97,13 @@ export const Env = {
|
|||
EMPTY: "",
|
||||
SPACE: " ",
|
||||
is: (value: unknown): value is string => typeof value === "string",
|
||||
} as const
|
||||
nonEmpty: (value: unknown): string | undefined => typeof value === "string" && value !== "" ? value : undefined,
|
||||
} as const,
|
||||
|
||||
bool: {
|
||||
isTrue: (value: unknown): value is boolean => typeof value === "boolean" && value === true,
|
||||
} as const,
|
||||
|
||||
} as const;
|
||||
|
||||
export type LoggerFn = (...args: unknown[]) => void;
|
||||
|
|
|
|||
|
|
@ -1,425 +0,0 @@
|
|||
import { ViewUpdate } from "@codemirror/view";
|
||||
import { App, ItemView, MarkdownPostProcessorContext, MarkdownView, TFile, View } from "obsidian";
|
||||
import { CacheManager, CacheRequest } from "./CacheManager";
|
||||
import { Env, LoggerFn } from "./Env";
|
||||
import { HtmlAssistant, HTMLElementCacheState } from "./HtmlAssistant";
|
||||
import { CodeMirrorAssistant } from "./utils/CodeMirrorAssistant";
|
||||
import { ObsAssistant, ObsViewMode } from "./utils/ObsAssistant";
|
||||
import { Url } from "./utils/Url";
|
||||
import { Workarounds } from "./Workarounds";
|
||||
|
||||
export class ProcessingPass {
|
||||
|
||||
private readonly view: View;
|
||||
private readonly itemView?: ItemView;
|
||||
private readonly markdownView?: MarkdownView;
|
||||
public readonly associatedFile: TFile;
|
||||
|
||||
/** `true` when invoked by the Markdown post processor. */
|
||||
public readonly isInPostProcessingPass: boolean;
|
||||
public readonly mode: ObsViewMode;
|
||||
private readonly viewUpdate?: ViewUpdate;
|
||||
public readonly log: LoggerFn;
|
||||
|
||||
public readonly passID: number;
|
||||
private static updatePassID: number = 0;
|
||||
private static postProcessorPassID: number = 0;
|
||||
|
||||
private constructor(id: number, isInPostProcessingPass: boolean, view: View, file: TFile, log: LoggerFn, viewUpdate?: ViewUpdate) {
|
||||
this.passID = id;
|
||||
this.isInPostProcessingPass = isInPostProcessingPass;
|
||||
this.view = view;
|
||||
this.itemView = view instanceof ItemView ? view : undefined;
|
||||
this.markdownView = view instanceof MarkdownView ? view : undefined;
|
||||
this.associatedFile = file;
|
||||
this.mode = ObsAssistant.viewMode(view);
|
||||
this.viewUpdate = viewUpdate;
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
public static beginFromViewUpdate(app: App, viewUpdate: ViewUpdate, options?: {
|
||||
/** Return `null` is view is in source mode. Defaults to `true`. */
|
||||
abortInSourceMode: boolean;
|
||||
noFile?: (id: number, log: LoggerFn) => void;
|
||||
}): ProcessingPass | null {
|
||||
|
||||
const thisID = this.updatePassID++;
|
||||
const log = Env.log.edit;
|
||||
const {
|
||||
abortInSourceMode = true,
|
||||
} = options || {};
|
||||
|
||||
const view = app.workspace.getActiveViewOfType(View);
|
||||
const associatedFile = ObsAssistant.getFileFromView(view);
|
||||
|
||||
if (view === null || associatedFile === null) {
|
||||
ProcessingPass.handleNoAssociatedFile(viewUpdate, thisID, log);
|
||||
options?.noFile?.(thisID, log);
|
||||
log(ProcessingPass.abortLogMsg(true, thisID));
|
||||
return null;
|
||||
}
|
||||
|
||||
const instance = new this(thisID, false, view, associatedFile, log, viewUpdate);
|
||||
|
||||
if (abortInSourceMode && instance.mode === "source") {
|
||||
log(ProcessingPass.abortLogMsg(true, thisID));
|
||||
return null;
|
||||
}
|
||||
|
||||
instance.log(instance.logMsg(`Start edit update pass in ${instance.mode} mode. ➡️🚪`));
|
||||
|
||||
if (Env.isDev && instance.mode === "preview") {
|
||||
const u: Record<string, boolean> = {
|
||||
docChanged: viewUpdate.docChanged,
|
||||
viewportChanged: viewUpdate.viewportChanged,
|
||||
selectionSet: viewUpdate.selectionSet,
|
||||
focusChanged: viewUpdate.focusChanged,
|
||||
geometryChanged: viewUpdate.geometryChanged,
|
||||
heightChanged: viewUpdate.heightChanged,
|
||||
viewportMoved: viewUpdate.viewportMoved,
|
||||
};
|
||||
const changed = Object.keys(u).filter(key => u[key]);
|
||||
const notChanged = Object.keys(u).filter(key => !u[key]);
|
||||
|
||||
instance.log(`\t${changed.length} view updates:`);
|
||||
if (changed.length > 0)
|
||||
instance.log(`\t🟢 ${changed.join(", ")}`);
|
||||
if (changed.length > 0 && notChanged.length > 0)
|
||||
instance.log(`\t🔴 ${notChanged.join(", ")}`);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
public static beginFromPostProcessorContext(app: App, context: MarkdownPostProcessorContext): ProcessingPass | null {
|
||||
|
||||
const thisID = this.postProcessorPassID++;
|
||||
const log = Env.log.read;
|
||||
log(Env.dev.icon.POST_PROCESS_PASS, "Start post processor pass ➡️🚪", ProcessingPass.idString(thisID));
|
||||
|
||||
const view = app.workspace.getActiveViewOfType(View);
|
||||
Env.dev.runDev(() => {
|
||||
log(Env.dev.icon.POST_PROCESS_PASS, "\tView type:", view !== null ? ObsAssistant.viewClassTypeAsSting(view) + " / " + view.getViewType() : "No view available");
|
||||
});
|
||||
|
||||
let associatedFile = ObsAssistant.getFileFromView(view);
|
||||
if (associatedFile === null)
|
||||
associatedFile = app.vault.getFileByPath(context.sourcePath);
|
||||
|
||||
if (view !== null && associatedFile !== null) {
|
||||
return new this(thisID, true, view, associatedFile, log);
|
||||
}
|
||||
else {
|
||||
log(ProcessingPass.abortLogMsg(false, thisID));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public end(cacheManager: CacheManager) {
|
||||
this.log(this.logMsg(this.isInUpdatePass ? "Finished update pass." : "Finished post processing pass."), "✅🚪➡️");
|
||||
|
||||
const options = {
|
||||
preventReleases: this.isInPostProcessingPass,
|
||||
requestsToIgnore: this.requestsToIgnore,
|
||||
};
|
||||
|
||||
cacheManager.updateRetainedCaches(Object.values(this.requestsToRetain), this.associatedFile.path, options).then(() => {
|
||||
this.log(this.logMsg("\tCalling saveMetadataIfDirty"));
|
||||
cacheManager.saveMetadataIfDirty();
|
||||
});
|
||||
}
|
||||
|
||||
public enqueue(operation: () => Promise<void>): Promise<void> {
|
||||
ProcessingPass.serialQueue = ProcessingPass.serialQueue.then(() => operation());
|
||||
return ProcessingPass.serialQueue;
|
||||
}
|
||||
private static serialQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
public get idString() {
|
||||
return "ID " + this.passID;
|
||||
}
|
||||
|
||||
public static idString(id: number) {
|
||||
return "ID " + id
|
||||
}
|
||||
|
||||
public abortLogMsg() {
|
||||
return ProcessingPass.abortLogMsg(this.isInUpdatePass, this.passID);
|
||||
}
|
||||
|
||||
public static abortLogMsg(isInUpdatePass: boolean, passID: number) {
|
||||
if (!Env.isDev)
|
||||
return Env.str.EMPTY;
|
||||
|
||||
if (isInUpdatePass)
|
||||
return `${Env.dev.icon.EDIT_UPDATE_PASS} Aborting update pass ❌🚪➡️ ${ProcessingPass.idString(passID)}`;
|
||||
else
|
||||
return `${Env.dev.icon.POST_PROCESS_PASS} Aborting post processing pass ❌🚪➡️ ${ProcessingPass.idString(passID)}`;
|
||||
}
|
||||
|
||||
public logMsg(...msg: string[]) {
|
||||
if (!Env.isDev)
|
||||
return Env.str.EMPTY;
|
||||
return `${this.isInUpdatePass ? Env.dev.icon.EDIT_UPDATE_PASS : Env.dev.icon.POST_PROCESS_PASS} ${msg.join(Env.str.SPACE)} ${this.idString}`;
|
||||
}
|
||||
|
||||
public get isInUpdatePass() {
|
||||
return !this.isInPostProcessingPass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only available if the {@link View} is an {@link ItemView}. If traversing descendants, {@link containerEl} can also be used.
|
||||
* @returns `<div class="view-content">` which is a descendant of {@link containerEl} and the parent of both the reader and source container elements.
|
||||
*/
|
||||
public get contentEl(): HTMLElement | null {
|
||||
return this.itemView?.contentEl ?? null;
|
||||
}
|
||||
|
||||
/** @returns The element `<div class="workspace-leaf-content" data-type="markdown" data-mode="X">` where `X` is `preview` if in reader view or `source` otherwise. {@link contentEl} is a descendant. */
|
||||
public get containerEl(): HTMLElement {
|
||||
return this.view.containerEl;
|
||||
}
|
||||
|
||||
public retainCache(urlOrCache: string | CacheRequest) {
|
||||
Env.assert(urlOrCache);
|
||||
Env.log.d(Env.dev.thunkedStr(() => this.logMsg(`retainCache: Will retain ${CacheManager.createCacheKeyFromOriginalSrc(Env.str.is(urlOrCache) ? urlOrCache : urlOrCache.source)} at the end of pass`)));
|
||||
|
||||
if (Env.str.is(urlOrCache)) {
|
||||
Env.assert(urlOrCache.length > 0);
|
||||
this.requestsToRetain[urlOrCache] = CacheManager.createRequest(urlOrCache, this.associatedFile.path);
|
||||
}
|
||||
else {
|
||||
this.requestsToRetain[urlOrCache.source] = urlOrCache;
|
||||
}
|
||||
}
|
||||
private requestsToRetain: Record<string, CacheRequest> = {};
|
||||
|
||||
public ignoreCache(src: string) {
|
||||
Env.assert(src);
|
||||
this.log(Env.dev.thunkedStr(() => this.logMsg(`ignoreCache: Will ignore ${CacheManager.createCacheKeyFromOriginalSrc(src)} at the end of pass`)));
|
||||
|
||||
this.requestsToIgnore.add(CacheManager.createRequest(src, this.associatedFile.path));
|
||||
}
|
||||
private requestsToIgnore = new Set<CacheRequest>();
|
||||
|
||||
public get currentNumberOfRequestsToRetain() {
|
||||
return Object.keys(this.requestsToRetain).length;
|
||||
}
|
||||
|
||||
/** No file/note info is available yet but the contentDOM is so cancel image loading. */
|
||||
public static handleNoAssociatedFile(update: ViewUpdate, id: number, log: LoggerFn) {
|
||||
log(Env.dev.icon.EDIT_UPDATE_PASS, "No associated file in this update. Will cancel all `img` tags found in current DOM.", ProcessingPass.idString(id));
|
||||
|
||||
const sourcesToIgnore = Workarounds.detectSourcesOfInvalidImageElements(update);
|
||||
|
||||
// There is no file to work with. All that can be done is to cancel loading.
|
||||
const imageElements = HtmlAssistant.findAllImageElements(update.view.contentDOM, true, (imageElement) => {
|
||||
const src = HtmlAssistant.getSrc(imageElement);
|
||||
|
||||
// Filter out image elements without a src or invalid.
|
||||
if (src === null || !Workarounds.handleInvalidImageElements(sourcesToIgnore, imageElement, src))
|
||||
return false;
|
||||
|
||||
// Allow image elements with external urls through so they can be cancelled.
|
||||
return HtmlAssistant.isImageToProcess(imageElement);
|
||||
});
|
||||
|
||||
HtmlAssistant.cancelImageLoading(imageElements);
|
||||
}
|
||||
|
||||
/**
|
||||
* `currentDOM` only includes nodes visible in the viewport (plus a margin).
|
||||
* This method makes sure the remaining images in the note are retained to prevent them from being deleted.
|
||||
*
|
||||
* @param imageElementsInDom Images in viewport/DOM returned by {@link findRelevantImagesToProcessViewUpdate} that have **already been canceled**.
|
||||
* @returns
|
||||
*/
|
||||
public handleImagesNotInCurrentDOM(imageElementsInDom: HTMLImageElement[]) {
|
||||
Env.assert(this.viewUpdate);
|
||||
if (!this.viewUpdate)
|
||||
return;
|
||||
|
||||
const imgSrcInDom = imageElementsInDom.map(el => {
|
||||
Env.dev.assert(HtmlAssistant.cacheState(el) !== HTMLElementCacheState.ORIGINAL);
|
||||
return HtmlAssistant.originalSrc(el, false);
|
||||
});
|
||||
|
||||
const imagesOutsideViewport = CodeMirrorAssistant.findAllImages(this.viewUpdate.view, (url) => {
|
||||
const normalizedUrl = Url.normalizeUrl(url);
|
||||
if (imgSrcInDom.includes(normalizedUrl))
|
||||
return false;
|
||||
return Url.isValidExternalUrl(normalizedUrl); // These urls might be local.
|
||||
});
|
||||
|
||||
this.log(Env.dev.thunkedStr(() => this.logMsg("Found " + imagesOutsideViewport?.length + " images outside viewport:" + imagesOutsideViewport?.map(f => f.src).join(", "))));
|
||||
imagesOutsideViewport?.forEach(image => this.retainCache(image.src));
|
||||
}
|
||||
|
||||
public static findRelevantImagesToProcessInPostProcessor(element: HTMLElement, _context: MarkdownPostProcessorContext, requireSrcAttribute: boolean): [imageElementsToProcess: HTMLImageElement[], remainingElements: HTMLImageElement[]] {
|
||||
const imageElements = HtmlAssistant.findAllImageElements(element, requireSrcAttribute);
|
||||
const remainingElements: HTMLImageElement[] = [];
|
||||
const elementsToProcess: HTMLImageElement[] = [];
|
||||
|
||||
const imagesToCancelFilter = (imageElement: HTMLImageElement) => {
|
||||
return Logic.filterIrrelevantCacheStates(imageElement) || HtmlAssistant.isImageToProcess(imageElement);
|
||||
};
|
||||
|
||||
for (const imageElement of imageElements) {
|
||||
if (imagesToCancelFilter(imageElement))
|
||||
elementsToProcess.push(imageElement);
|
||||
else
|
||||
remainingElements.push(imageElement);
|
||||
}
|
||||
|
||||
return [elementsToProcess, remainingElements];
|
||||
}
|
||||
|
||||
public findRelevantImagesToProcessViewUpdate(update: ViewUpdate): [imageElementsToProcess: HTMLImageElement[], remainingElements: HTMLImageElement[]] {
|
||||
|
||||
// Elements in DOM at this stage might be in states in which the `src` attribute has been removed. Therefore the `src` attribute is not required when finding image elements.
|
||||
const imageElements = HtmlAssistant.findAllImageElements(update.view.contentDOM, false);
|
||||
const sourcesToIgnore = Workarounds.detectSourcesOfInvalidImageElements(update);
|
||||
|
||||
const remainingElements: HTMLImageElement[] = [];
|
||||
const imagesToCancel: HTMLImageElement[] = [];
|
||||
|
||||
const imagesToCancelFilter = (imageElement: HTMLImageElement) => {
|
||||
const src = HtmlAssistant.getSrc(imageElement);
|
||||
|
||||
// 2. If there's no src there's nothing left to do but to remove all states that have passed this stage already.
|
||||
if (src === null)
|
||||
return Logic.filterIrrelevantCacheStates(imageElement);
|
||||
|
||||
// 3. Check if this image element should be ignored.
|
||||
if (!Workarounds.handleInvalidImageElements(sourcesToIgnore, imageElement, src))
|
||||
return false;
|
||||
|
||||
// 3. Only external urls are relevant.
|
||||
// If not negating and returning false, local urls won't be cached here
|
||||
if (!HtmlAssistant.isImageToProcess(imageElement))
|
||||
return false;
|
||||
|
||||
// 4. At this stage filtering based on urls should be done and here just look at states to remove all states that have passed this stage already.
|
||||
return Logic.filterIrrelevantCacheStates(imageElement);
|
||||
};
|
||||
|
||||
for (const imageElement of imageElements) {
|
||||
|
||||
// 1. First check if a new URL was set on an existing element so that it will be canceled.
|
||||
this.checkIfUrlChanged(update, imageElement);
|
||||
|
||||
if (imagesToCancelFilter(imageElement))
|
||||
imagesToCancel.push(imageElement);
|
||||
else
|
||||
remainingElements.push(imageElement);
|
||||
}
|
||||
|
||||
return [imagesToCancel, remainingElements];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether the url of an already existing image elemetent was changed by the user, and if so, treats the image element as newly added.
|
||||
*
|
||||
* - Should be initial processing step in the editor listener (or at least before canceling starts).
|
||||
* - Note: This must be the first thing that happens since the state is reset, i.e., it effectively aborts any actions that would've been taken on other states.
|
||||
* - That said, the previous element might have been deleted by the system and a new element created, in which case this method does nothing.
|
||||
*
|
||||
* @param update
|
||||
* @param imageElement
|
||||
*/
|
||||
private checkIfUrlChanged(update: ViewUpdate, imageElement: HTMLImageElement) {
|
||||
Env.log.d(this.logMsg("ProcessingPass:resetIfNeeded"), update.docChanged, HtmlAssistant.cacheState(imageElement), HtmlAssistant.getSrc(imageElement), Url.isValidExternalUrl(HtmlAssistant.getSrc(imageElement)));
|
||||
|
||||
// These all come together to reveal that the src has changed and thus is treated as a new image.
|
||||
// - `update.docChanged`: user edited
|
||||
// - `HtmlAssistant.cacheState(imageElement) != HTMLElementCacheState.ORIGINAL`: Only unprocessed/"original" image elements have `src` set to external urls...
|
||||
// - `Logic.isValidExternalUr`: ...but this one *has* such url.
|
||||
// Therefore the src was modified, which is tantamount to a separate, added image element, so the state need to be reset and it will be treated as such.
|
||||
// The cache reference will be released as it is not retained now.
|
||||
|
||||
if (update.docChanged && HtmlAssistant.cacheState(imageElement) != HTMLElementCacheState.ORIGINAL && Url.isValidExternalUrl(HtmlAssistant.getSrc(imageElement))) {
|
||||
this.log(Env.dev.thunkedStr(() => this.logMsg(`Url changed on image from ${HtmlAssistant.originalSrc(imageElement)} to ${HtmlAssistant.getSrc(imageElement)}`)));
|
||||
HtmlAssistant.resetElement(imageElement); // Set state to "untouched".
|
||||
}
|
||||
}
|
||||
|
||||
/** Makes sure that elements requesting cache, waiting for download, or already cached, are not released when {@link end} is called. */
|
||||
public handleRequestingAndSucceeded(imageElements: HTMLImageElement[]) {
|
||||
Env.log.d("ProcessingPass:handleActive:", imageElements);
|
||||
for (const imageElement of imageElements) {
|
||||
// As all images are retained in each pass, even though elements that are already cached are excluded from further processing, they still need to be retained.
|
||||
if (HtmlAssistant.cacheState(imageElement) == HTMLElementCacheState.CACHE_SUCCEEDED) {
|
||||
const src = HtmlAssistant.originalSrc(imageElement);
|
||||
Env.assert(src !== null, "Expected original source dataset");
|
||||
if (src)
|
||||
this.retainCache(src);
|
||||
}
|
||||
|
||||
if (HtmlAssistant.isElementCacheStateEqual(imageElement, HTMLElementCacheState.REQUESTING, HTMLElementCacheState.REQUESTING_DOWNLOADING)) {
|
||||
const src = HtmlAssistant.originalSrc(imageElement);
|
||||
Env.assert(src !== null, "Expected original source dataset");
|
||||
if (src)
|
||||
this.ignoreCache(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static sleep(milliseconds: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously waits for an element to be attached to the DOM.
|
||||
* @param element The element to check.
|
||||
* @param timeoutMs The maximum time to wait in milliseconds.
|
||||
* @returns A promise that resolves when the element has a parent, or rejects on timeout.
|
||||
*/
|
||||
public static waitForElementAttachment(element: HTMLElement, timeoutMs = 500, delay = 1): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
const check = () => {
|
||||
if (element.parentElement)
|
||||
resolve();
|
||||
else if (Date.now() - startTime > timeoutMs)
|
||||
reject(new Error(`Element was not attached to the DOM within ${timeoutMs}ms.`));
|
||||
else
|
||||
setTimeout(check, delay);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class Logic {
|
||||
|
||||
/**
|
||||
* Remove requesting, downloading, done, and invalid.
|
||||
* - The done image element is already pointing to the cached resource. No need to do anything more.
|
||||
* - Those that are downloading will be handled as the download finishes as part of a previous pass.
|
||||
*
|
||||
* Keep
|
||||
* - Original: these need to be cancelled
|
||||
* - Cancelled and Failed: these need to request cache.
|
||||
*
|
||||
* @param imageElement
|
||||
* @returns `false` if state of {@link imageElement} is one of the above mentioned irrelevant states.
|
||||
*/
|
||||
public static filterIrrelevantCacheStates(imageElement: HTMLImageElement) {
|
||||
Env.log.d(Env.dev.icon.NONE, "ProcessingPass:filterIrrelevantCacheStates");
|
||||
const state = HtmlAssistant.cacheState(imageElement)
|
||||
|
||||
// return HtmlAssistant.isCacheStateEqual(state, [
|
||||
// HTMLElementCacheState.ORIGINAL,
|
||||
// HTMLElementCacheState.ORIGINAL_SRC_REMOVED,
|
||||
// HTMLElementCacheState.CACHE_FAILED
|
||||
// ]);
|
||||
|
||||
// Difference between this and the above is that ORIGINAL matches all unprocessed elements.
|
||||
return !HtmlAssistant.isCacheStateEqual(state,
|
||||
HTMLElementCacheState.REQUESTING,
|
||||
HTMLElementCacheState.REQUESTING_DOWNLOADING,
|
||||
HTMLElementCacheState.CACHE_SUCCEEDED,
|
||||
HTMLElementCacheState.INVALID
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { CacheManager } from "cache/CacheManager";
|
||||
import { Env } from "Env";
|
||||
import { Platform, Plugin, PluginSettingTab, Setting } from "obsidian";
|
||||
import { CacheManager } from "./CacheManager";
|
||||
import { Env } from "./Env";
|
||||
import { Notice } from "./ui/Notice";
|
||||
import { Notice } from "ui/Notice";
|
||||
|
||||
export interface PluginSettings {
|
||||
|
||||
|
|
|
|||
76
src/CacheManager.ts → src/cache/CacheManager.ts
vendored
76
src/CacheManager.ts → src/cache/CacheManager.ts
vendored
|
|
@ -1,9 +1,11 @@
|
|||
import { CacheMetadata, CacheMetadataImage, CacheRetainer, CacheRoot, CacheType, EMPTY_CACHE_ROOT } from "cache/CacheMetadata";
|
||||
import { Env } from "Env";
|
||||
import { imageSize } from "image-size";
|
||||
import { normalizePath, requestUrl, Vault } from "obsidian";
|
||||
import { Logger } from "utils/Logger";
|
||||
import { Url } from "utils/Url";
|
||||
import { Err } from "utils/ts";
|
||||
import xxhash, { XXHashAPI } from "xxhash-wasm";
|
||||
import { CacheMetadata, CacheMetadataImage, CacheRetainer, CacheRoot, CacheType, EMPTY_CACHE_ROOT } from "./CacheMetadata";
|
||||
import { Env } from "./Env";
|
||||
import { Url } from "./utils/Url";
|
||||
|
||||
|
||||
export interface CacheRequest {
|
||||
|
|
@ -15,6 +17,8 @@ export interface CacheRequest {
|
|||
|
||||
/** @todo Can do without. However, this makes each {@link CacheRequest} unique per requester. */
|
||||
requesterPath: string;
|
||||
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export interface CacheItem {
|
||||
|
|
@ -118,7 +122,7 @@ export type MetadataChanged = (data: CacheRoot) => void;
|
|||
|
||||
export class CacheManager {
|
||||
|
||||
private metadataRoot: CacheRoot;
|
||||
private metadataRoot: CacheRoot = EMPTY_CACHE_ROOT;
|
||||
|
||||
private get cache(): Record<string, CacheMetadata> {
|
||||
return this.metadataRoot.items;
|
||||
|
|
@ -215,15 +219,20 @@ export class CacheManager {
|
|||
requestsToIgnore?: Set<CacheRequest>;
|
||||
/** If `true` will remove any cache references on the associated {@link CacheRetainer|retainer}. */
|
||||
preventReleases?: boolean;
|
||||
/** Caller's logger */
|
||||
logger?: Logger;
|
||||
}) {
|
||||
const { requestsToIgnore, preventReleases = false } = options || {};
|
||||
const { requestsToIgnore, preventReleases = false, logger } = options || {};
|
||||
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, Env.dev.thunkedStr(() => `updateRetainedCaches: \n\t${requests.length} retain requests:\n\t\t${requests.map(r => r.source).join("\n\t\t")} \n\tRetainer: ${retainerPath}`));
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "updateRetainedCaches");
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Retainer: ", retainerPath);
|
||||
Env.dev.runDev(() => {
|
||||
requests.forEach(request => Env.assert(request.requesterPath === retainerPath, `Expected retainer of cache request (${request.source}) equal to ${retainerPath}`))
|
||||
Env.log.cm(`\tTo ignore count: ${requestsToIgnore ? requestsToIgnore.size : 0}, preventCacheRelases: ${preventReleases}`);
|
||||
Env.log.cm("\tCurrent retain count: ", this.retainCount());
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", `${requests.length} retain requests:`);
|
||||
requests.forEach(request => Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t\t", request.source));
|
||||
});
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", `To ignore count: ${requestsToIgnore ? requestsToIgnore.size : 0}, preventCacheRelases: ${preventReleases}`);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Current retain count: ", this.retainCount());
|
||||
|
||||
// If retainer/file does not exist / is not yet registered, it will be.
|
||||
let retainer = this.metadataRoot.retainers[retainerPath];
|
||||
|
|
@ -240,7 +249,7 @@ export class CacheManager {
|
|||
cacheKeysRequestedReferenced.push(cacheKey);
|
||||
}
|
||||
else {
|
||||
Env.log.cm("\tCache key does not (yet) exist:", cacheKey);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\tCache key does not (yet) exist:", cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -260,24 +269,31 @@ export class CacheManager {
|
|||
.filter(key => !ignoredReferences.includes(key));
|
||||
|
||||
if (addedReferences.length == 0 && removedReferences.length == 0) {
|
||||
Env.log.cm(`\tNo change: all requested cache keys match the already referenced/retained keys. Aborting.`)
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", `No change: all requested cache keys match the already referenced/retained keys. Aborting.`)
|
||||
return;
|
||||
}
|
||||
|
||||
Env.log.cm("\tRequested cache keys:", cacheKeysRequestedReferenced);
|
||||
Env.log.cm("\tRetaining:", addedReferences);
|
||||
Env.log.cm("\tReleasing:", removedReferences);
|
||||
Env.log.cm("\tIgnoring:", ignoredReferences);
|
||||
// Input validation: Prevent removeal cache items that are not referenced by the retainer. This should not happen.
|
||||
const invalidRemovedReferences = removedReferences.filter(key => !cacheKeysCurrentlyReferenced.includes(key));
|
||||
const validRemovedReferences = removedReferences.filter(key => !invalidRemovedReferences.includes(key));
|
||||
if (invalidRemovedReferences.length > 0)
|
||||
Env.log.e(`Attempted to remove the following cache references from retainer '${retainerPath}' which does not reference them ${logger?.idString ?? Env.str.EMPTY}:`, invalidRemovedReferences);
|
||||
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Requested cache keys:", cacheKeysRequestedReferenced);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Retaining:", addedReferences);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Releasing:", validRemovedReferences);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Ignoring:", ignoredReferences);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Not releasing (invalid):", invalidRemovedReferences);
|
||||
|
||||
let newRef = retainer !== undefined ? [...retainer.ref, ...addedReferences] : addedReferences;
|
||||
newRef = newRef.filter(r => !removedReferences.includes(r));
|
||||
newRef = newRef.filter(r => !validRemovedReferences.includes(r));
|
||||
this.setRetainerRefs(retainerPath, newRef);
|
||||
|
||||
const updatedRetainCount = this.retainCount();
|
||||
Env.log.cm("\tUpdated retain count: ", updatedRetainCount);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Updated retain count: ", updatedRetainCount);
|
||||
|
||||
// Remove caches that are no longer referenced by any retainer.
|
||||
for (const removedReference of removedReferences) {
|
||||
for (const removedReference of validRemovedReferences) {
|
||||
if (!this.isRetained(updatedRetainCount, removedReference))
|
||||
await this.removeCacheItem(removedReference);
|
||||
}
|
||||
|
|
@ -370,7 +386,7 @@ export class CacheManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* If {@link cacheKey} is found in metadata, will removed the associated file from cache and from the metadata.
|
||||
* If {@link cacheKey} is found in metadata, will remove the associated file from cache and from the metadata.
|
||||
*
|
||||
* {@link saveMetadataIfDirty} needs to be called at some point to persist the matadata.
|
||||
*
|
||||
|
|
@ -380,7 +396,7 @@ export class CacheManager {
|
|||
const metadata = this.cache[cacheKey];
|
||||
Env.assert(metadata !== undefined, `Attempted to remove cash item using a non-existing key.`);
|
||||
if (metadata !== undefined) {
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, `removeCacheItem\n\tRemoving ${this.nameOfCachedFileFromMetadata(metadata, cacheKey)}`);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "removeCacheItem", "\n\tRemoving", this.nameOfCachedFileFromMetadata(metadata, cacheKey));
|
||||
await this.vault.adapter.remove(this.filePathToCachedFileFromMetadata(metadata, cacheKey));
|
||||
delete this.cache[cacheKey];
|
||||
this.isMetadataDirty = true;
|
||||
|
|
@ -547,6 +563,10 @@ export class CacheManager {
|
|||
return new CacheResult(request, cacheKey, null, new CacheNotFoundError(cacheKey), undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* @remarks `vault.adapter.getResourcePath` adds (what looks like) a timestamp parameter to the end of the {@link CacheItem.resourcePath},
|
||||
* e.g., "…/dbc4b9faa6ffacd2.png?1759472349631".
|
||||
*/
|
||||
private createCacheItem(metadata: CacheMetadata, fromCache: boolean): CacheItem {
|
||||
return {
|
||||
resourcePath: this.vault.adapter.getResourcePath(this.filePathToCachedFileFromMetadata(metadata)),
|
||||
|
|
@ -621,7 +641,7 @@ export class CacheManager {
|
|||
result = await this.download(request, sourceFileInfo);
|
||||
}
|
||||
catch (error) {
|
||||
result = new CacheResult(request, cacheKey ?? "", null, error);
|
||||
result = new CacheResult(request, cacheKey ?? Env.str.EMPTY, null, Err.toError(error));
|
||||
}
|
||||
finally {
|
||||
if (cacheKey)
|
||||
|
|
@ -649,7 +669,7 @@ export class CacheManager {
|
|||
|
||||
callback?.();
|
||||
} catch (error) {
|
||||
callback?.(error);
|
||||
callback?.(Err.toError(error));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -791,11 +811,17 @@ export class CacheManager {
|
|||
return CacheManager.hasher.h64Raw(bytes).toString(16).padStart(16, '0');
|
||||
}
|
||||
|
||||
public static createRequest(src: string, path: string): CacheRequest {
|
||||
public static createRequest(src: string, path: string, isReadOnly: boolean = false): CacheRequest {
|
||||
const source = src.trim();
|
||||
Env.assert(source.length > 0);
|
||||
const requesterPath = path.trim();
|
||||
Env.assert(source.length > 0 && requesterPath.length > 0);
|
||||
return { source, requesterPath } satisfies CacheRequest;
|
||||
Env.assert(isReadOnly || requesterPath.length > 0);
|
||||
return { source, requesterPath, isReadOnly: isReadOnly } satisfies CacheRequest;
|
||||
}
|
||||
|
||||
/** @todo Also see {@link CacheRequest#requesterPath}. */
|
||||
public static createReadOnlyRequest(src: string): CacheRequest {
|
||||
return CacheManager.createRequest(src, Env.str.EMPTY, true);
|
||||
}
|
||||
|
||||
public static isRequestEqual(request: CacheRequest, otherRequest: CacheRequest) {
|
||||
|
|
@ -938,7 +964,7 @@ export class CacheManager {
|
|||
if (error instanceof CacheError)
|
||||
result = new CacheResult(request, cacheKey, null, error);
|
||||
else
|
||||
result = new CacheResult(request, cacheKey, null, new CacheFetchError(cacheKey, error, { sourceUrl: sourceUrl }));
|
||||
result = new CacheResult(request, cacheKey, null, new CacheFetchError(cacheKey, Err.toError(error), { sourceUrl: sourceUrl }));
|
||||
}
|
||||
|
||||
return result;
|
||||
245
src/main.ts
245
src/main.ts
|
|
@ -1,14 +1,16 @@
|
|||
import { EditorView, ViewUpdate } from "@codemirror/view";
|
||||
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
|
||||
import { CacheFetchError, CacheItem, CacheManager, CacheRequest, CacheTypeError } from "cache/CacheManager";
|
||||
import { Env } from "Env";
|
||||
import { MarkdownPostProcessorContext, Plugin, TFile, normalizePath } from "obsidian";
|
||||
import { CacheFetchError, CacheItem, CacheManager, CacheRequest, CacheTypeError } from "./CacheManager";
|
||||
import { Env } from "./Env";
|
||||
import { HTMLElementAttribute, HTMLElementCacheState, HtmlAssistant } from "./HtmlAssistant";
|
||||
import { ProcessingPass } from "./ProcessingPass";
|
||||
import { PluginSettings, SettingTab, SettingsManager } from "./Settings";
|
||||
import { InfoModal } from "./ui/InfoModal";
|
||||
import { Notice } from "./ui/Notice";
|
||||
import { File } from "./utils/File";
|
||||
import { ObsAssistant } from "./utils/ObsAssistant";
|
||||
import { EditorViewPlugin, EditorViewPluginInfo } from "processing/EditorViewPlugin";
|
||||
import { HTMLElementAttribute, HTMLElementCacheState, HtmlAssistant } from "processing/HtmlAssistant";
|
||||
import { ProcessingContext } from "processing/ProcessingContext";
|
||||
import { ProcessingPass } from "processing/ProcessingPass";
|
||||
import { PluginSettings, SettingTab, SettingsManager } from "Settings";
|
||||
import { InfoModal } from "ui/InfoModal";
|
||||
import { Notice } from "ui/Notice";
|
||||
import { queueAsyncMicrotask, sleep } from "utils/dom";
|
||||
import { File } from "utils/File";
|
||||
|
||||
|
||||
interface PluginData {
|
||||
|
|
@ -20,9 +22,9 @@ const DEFAULT_DATA: PluginData = {
|
|||
} as const;
|
||||
|
||||
export default class ComeDownPlugin extends Plugin {
|
||||
private data: PluginData;
|
||||
private settingsManager: SettingsManager;
|
||||
private cacheManager: CacheManager;
|
||||
private data!: PluginData;
|
||||
private settingsManager!: SettingsManager;
|
||||
private cacheManager!: CacheManager;
|
||||
|
||||
async onload() {
|
||||
Env.log.d("Plugin:onload");
|
||||
|
|
@ -46,7 +48,12 @@ export default class ComeDownPlugin extends Plugin {
|
|||
);
|
||||
this.addSettingTab(new SettingTab(this, this.settingsManager, this.cacheManager));
|
||||
|
||||
this.registerEditorExtension(EditorView.updateListener.of((vu) => this.editorViewUpdateListener(vu)));
|
||||
const viewPlugin = ViewPlugin.fromClass(EditorViewPlugin);
|
||||
this.registerEditorExtension([
|
||||
viewPlugin,
|
||||
EditorView.updateListener.of((update) => update.view.plugin(viewPlugin)?.postUpdate(update, this.editorViewUpdateListener.bind(this)))
|
||||
]);
|
||||
|
||||
this.registerMarkdownPostProcessor((e, c) => this.markdownPostProcessor(e, c));
|
||||
|
||||
this.registerEvent(
|
||||
|
|
@ -139,9 +146,9 @@ export default class ComeDownPlugin extends Plugin {
|
|||
}
|
||||
return this.cacheDirBacking;
|
||||
}
|
||||
private cacheDirBacking?: string;
|
||||
private gitIgnorePath: string;
|
||||
private cacheMetadataPath: string;
|
||||
private cacheDirBacking?: string = undefined;
|
||||
private gitIgnorePath: string = Env.str.EMPTY;
|
||||
private cacheMetadataPath: string = Env.str.EMPTY;
|
||||
|
||||
/**
|
||||
* Ensures the cache directory exists.
|
||||
|
|
@ -216,105 +223,171 @@ export default class ComeDownPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
private editorViewUpdateListener(update: ViewUpdate) {
|
||||
private editorViewUpdateListener(update: ViewUpdate, plugin: EditorViewPlugin, info: EditorViewPluginInfo) {
|
||||
Env.log.d("Plugin:editorViewUpdateListener");
|
||||
|
||||
const pass = ProcessingPass.beginFromViewUpdate(this.app, update, { abortInSourceMode: true });
|
||||
if (!pass)
|
||||
const l = ProcessingPass.createViewUpdateLogger();
|
||||
l.log(l.beginMsg("seq:", info.seqNum));
|
||||
|
||||
const ctx: ProcessingContext = ProcessingContext.fromViewUpdate(this.app, l, update);
|
||||
ProcessingContext.logInit(ctx);
|
||||
|
||||
if (ProcessingPass.abortIfInvalidContext(ctx, plugin, info))
|
||||
return;
|
||||
|
||||
// No need to cancel anything in source mode. In reader mode, just cancel then abort. Leave it to the markdown post processor.
|
||||
const [imagesToCancel] = pass.findRelevantImagesToProcessViewUpdate(update);
|
||||
HtmlAssistant.cancelImageLoading(imagesToCancel);
|
||||
if (ProcessingPass.abortIfMode(ctx, "source")) // No need to cancel anything in source mode.
|
||||
return;
|
||||
|
||||
if (pass.mode === "reader" || ComeDownPlugin.canAbortViewUpdate(imagesToCancel, update)) {
|
||||
pass.log(pass.abortLogMsg());
|
||||
// Find elements that needs to be cancelled, or if they already are cancelled, they need to be processed further, e.g. requesting cache.
|
||||
const { elementsToProcess } = ProcessingPass.findRelevantImagesToProcessViewUpdate(ctx);
|
||||
HtmlAssistant.cancelImageLoadIfNeeded(elementsToProcess);
|
||||
|
||||
if (ProcessingPass.abortIfMode(ctx, "reader")) // If reader mode, just cancel then abort. Post processor will handle it.
|
||||
return;
|
||||
|
||||
// There are a few cases where a view update is called even though there are no actual updates. For example when switching a file in Obsidian.
|
||||
ctx.assertViewUpdateContext();
|
||||
if (!ctx.vuCtx.hasUpdates) {
|
||||
l.log(l.abortMsg("no updates"));
|
||||
return;
|
||||
}
|
||||
|
||||
pass.enqueue(async () => {
|
||||
// Read again to get the latest states (e.g. downloading).
|
||||
const [imagesToProcess, remainingElements] = pass.findRelevantImagesToProcessViewUpdate(update);
|
||||
pass.log(Env.dev.icon.EDIT_UPDATE_PASS, Env.dev.thunkedStr(() => `Found ${imagesToProcess.length} of ${imagesToProcess.length + remainingElements.length} images to process in current DOM. Running in serial queue. ${pass.idString}`));
|
||||
// Abort if there is nothing to do.
|
||||
if (elementsToProcess.length === 0 && !ComeDownPlugin.checkRemovals(update)) {
|
||||
l.log(l.abortMsg(l.t(() => `0 elements; focusChanged: ${update.focusChanged}; docChanged: ${update.docChanged}`)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (ComeDownPlugin.canAbortViewUpdate(imagesToProcess, update)) {
|
||||
pass.log(pass.abortLogMsg());
|
||||
// Wait for each pass to set states before next pass is allowed.
|
||||
queueAsyncMicrotask(async () => {
|
||||
l.log(l.msg("Running in serial queue. 🚶🏼🚶🏼🚶🏼"));
|
||||
|
||||
// Create a new context to reflect possible DOM changes.
|
||||
const pass = new ProcessingPass(ProcessingContext.fromViewUpdate(this.app, l, update));
|
||||
|
||||
if (Env.isDev && !pass.ctx.domEquals(ctx))
|
||||
ProcessingContext.logInit(pass.ctx);
|
||||
|
||||
if (ProcessingPass.abortIfMode(pass.ctx, "source", "reader"))
|
||||
return;
|
||||
|
||||
// Read again to get the latest states (e.g. downloading).
|
||||
const {
|
||||
elementsToProcess,
|
||||
remainingElements
|
||||
} = ProcessingPass.findRelevantImagesToProcessViewUpdate(pass.ctx);
|
||||
|
||||
// Abort if there is nothing to do.
|
||||
if (elementsToProcess.length === 0 && !ComeDownPlugin.checkRemovals(update)) {
|
||||
l.log(l.abortMsg(l.t(() => `queue: 0 elements; focusChanged: ${update.focusChanged}; docChanged: ${update.docChanged}`)));
|
||||
return;
|
||||
}
|
||||
|
||||
l.log(l.msg(l.t(() => `Found ${elementsToProcess.length} of ${elementsToProcess.length + remainingElements.length} images to process in current DOM.`)));
|
||||
|
||||
pass.handleRequestingAndSucceeded(remainingElements);
|
||||
pass.handleImagesNotInCurrentDOM([...elementsToProcess, ...remainingElements]);
|
||||
|
||||
// Special case when the user deletes an existing embed and all other image elements were filtered out: there are either no other embeds or all the other are already done.
|
||||
// Even when there are no more images to display, the user might remove image embeds, which needs to be removed from the cache as well.
|
||||
if (elementsToProcess.length === 0 && ComeDownPlugin.checkRemovals(update)) {
|
||||
l.log(l.msg("Checking removals (no images to process but document changed)."));
|
||||
pass.end(this.cacheManager);
|
||||
}
|
||||
else {
|
||||
|
||||
pass.handleRequestingAndSucceeded(remainingElements);
|
||||
pass.handleImagesNotInCurrentDOM([...imagesToProcess, ...remainingElements]);
|
||||
|
||||
// Special case when the user deletes an existing embed and all other image elements were filtered out: there are either no other embeds or all the other are already done.
|
||||
// Even when there are no more images to display, the user might remove image embeds, which needs to be removed from the cache as well.
|
||||
if (imagesToProcess.length === 0 && update.docChanged) {
|
||||
pass.log(pass.logMsg("Checking removals (no images to process but document changed)."));
|
||||
pass.end(this.cacheManager);
|
||||
}
|
||||
else {
|
||||
await this.requestCache(imagesToProcess, pass);
|
||||
}
|
||||
await this.requestCache(elementsToProcess, pass);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Refactored out of {@link editorViewUpdateListener} as a safty and self-documenting measure. See where `update.docChanged` in {@link editorViewUpdateListener}. */
|
||||
private static canAbortViewUpdate = (elements: HTMLImageElement[], update: ViewUpdate) => elements.length === 0 && !update.docChanged;
|
||||
/**
|
||||
* This could always return `true`. It is just used to avoid unnecessary processing.
|
||||
* - `focusChange`: if an image was removed externally, will be `true` when the note is opened, so that it its reference can be released.
|
||||
* - `docChanged`: user removes an image.
|
||||
*/
|
||||
private static checkRemovals = (update: ViewUpdate) => update.focusChanged || update.docChanged;
|
||||
|
||||
/**
|
||||
* @param element A chunk of HTML converted from markdown — not yet attached to the DOM.
|
||||
* @param context
|
||||
*/
|
||||
private markdownPostProcessor(element: HTMLElement, context: MarkdownPostProcessorContext) {
|
||||
private markdownPostProcessor(element: HTMLElement, procContext: MarkdownPostProcessorContext) {
|
||||
Env.log.d("Plugin:markdownPostProcessor");
|
||||
|
||||
const [imagesToProcess, remainingElements] = ProcessingPass.findRelevantImagesToProcessInPostProcessor(element, context, true);
|
||||
HtmlAssistant.cancelImageLoading(imagesToProcess);
|
||||
const l = ProcessingPass.createPostProcessorLogger();
|
||||
l.log(l.beginMsg());
|
||||
|
||||
const pass = ProcessingPass.beginFromPostProcessorContext(this.app, context);
|
||||
if (pass === null)
|
||||
const ctx = ProcessingContext.fromPostProcessor(this.app, l, element, procContext);
|
||||
ProcessingContext.logInit(ctx);
|
||||
|
||||
if (ProcessingPass.abortIfMode(ctx, "source")) // No need to cancel anything in source mode.
|
||||
return;
|
||||
|
||||
if (imagesToProcess.length === 0) {
|
||||
pass.log(pass.abortLogMsg());
|
||||
const { elementsToProcess } = ProcessingPass.findRelevantImagesToProcessInPostProcessor(element, true);
|
||||
HtmlAssistant.cancelImageLoadIfNeeded(elementsToProcess);
|
||||
|
||||
if (ProcessingPass.abortIfMode(ctx, "preview")) // If preview mode, cancel then abort.
|
||||
return;
|
||||
|
||||
if (elementsToProcess.length === 0) {
|
||||
l.log(l.abortMsg("elementsToProcess:", elementsToProcess.length));
|
||||
return;
|
||||
}
|
||||
|
||||
pass.enqueue(async () => {
|
||||
pass.log(pass.logMsg("Running in serial queue."));
|
||||
// Wait for each pass to set states before next pass is allowed.
|
||||
queueAsyncMicrotask(async () => {
|
||||
l.log(l.msg("Running in serial queue. 🚶🏼🚶🏼🚶🏼"));
|
||||
|
||||
const readerContainerEl = ObsAssistant.readerContainerEl(pass.contentEl ?? pass.containerEl);
|
||||
Env.assert(readerContainerEl);
|
||||
if (readerContainerEl === null) {
|
||||
pass.log(pass.abortLogMsg());
|
||||
// By now, the HTML element chunk that was provided with this post processing callback
|
||||
// should have been attached to the DOM if it is visible or just outside the viewport.
|
||||
|
||||
// Wait for subsequent elements to be attached inorder to group more elements in to the same pass.
|
||||
// This is merely to reduce the number of download notices shown to the user.
|
||||
// There's a cap to how many elements can be attached because the viewport size is limited. 5ms seems to be enough, so 10.
|
||||
await sleep(10);
|
||||
|
||||
// Create a new context to reflect possible DOM changes after being queued and slept.
|
||||
const pass: ProcessingPass = new ProcessingPass(ProcessingContext.fromPostProcessor(this.app, l, element, procContext));
|
||||
pass.ctx.assertPostProcessor();
|
||||
|
||||
if (Env.isDev && !pass.ctx.domEquals(ctx))
|
||||
ProcessingContext.logInit(pass.ctx);
|
||||
|
||||
if (ProcessingPass.abortIfMode(pass.ctx, "source", "preview"))
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait a bit for all (or some more) elements to be attached to the DOM so that requests can be grouped. This is solely to reduce the number of download notices; it does not affect functionality.
|
||||
await ProcessingPass.sleep(5);
|
||||
const containerEl = pass.ctx.getPreferredContainerEl();
|
||||
|
||||
// Read again to get the latest states (e.g. downloading).
|
||||
const [imagesToProcessInDom, remainingElementsInDom] = ProcessingPass.findRelevantImagesToProcessInPostProcessor(readerContainerEl, context, false);
|
||||
const {
|
||||
elementsToProcess: elementsToProcessInDom,
|
||||
remainingElements
|
||||
} = ProcessingPass.findRelevantImagesToProcessInPostProcessor(containerEl, false);
|
||||
|
||||
// The element/chunk that was provided in this post processing callback is not within the viewport (+margin), but it needs to be handled now anyway because this callback is only called once for each chunk.
|
||||
// If the HTML element chunk, that was provided with this post processing callback,
|
||||
// is not yet attached to the DOM (at this point executing in the queue),
|
||||
// it needs to be included, as it could not have be found above.
|
||||
//
|
||||
// TODO: These could be collected and handled together similar to the ones already attached.
|
||||
let imagesToProcessInCurrentElement: HTMLImageElement[] = [];
|
||||
if (element.parentElement === null)
|
||||
[imagesToProcessInCurrentElement] = ProcessingPass.findRelevantImagesToProcessInPostProcessor(element, context, false);
|
||||
if (!pass.ctx.ppCtx.isElementAttached(containerEl))
|
||||
imagesToProcessInCurrentElement = ProcessingPass.findRelevantImagesToProcessInPostProcessor(element, false).elementsToProcess;
|
||||
|
||||
pass.log(pass.logMsg(Env.dev.thunkedStr(() => `Found ${imagesToProcessInDom.length} of ${imagesToProcessInDom.length + remainingElementsInDom.length} images in DOM and ${imagesToProcessInCurrentElement.length} images in current HTML chunk to process.`)));
|
||||
await this.requestCache([...imagesToProcessInDom, ...imagesToProcessInCurrentElement], pass);
|
||||
l.log(l.t(() => l.msg(`Found ${elementsToProcessInDom.length} of ${elementsToProcessInDom.length + remainingElements.length} images in DOM and ${imagesToProcessInCurrentElement.length} images in current HTML chunk to process.`)));
|
||||
await this.requestCache([...elementsToProcessInDom, ...imagesToProcessInCurrentElement], pass);
|
||||
});
|
||||
}
|
||||
|
||||
async requestCache(imageElements: HTMLImageElement[], pass: ProcessingPass) {
|
||||
const l = pass.ctx.logr;
|
||||
|
||||
imageElements = imageElements.filter((imageElement) => HtmlAssistant.isElementCacheStateEqual(imageElement, HTMLElementCacheState.ORIGINAL_SRC_REMOVED, HTMLElementCacheState.CACHE_FAILED));
|
||||
|
||||
pass.log(Env.dev.thunkedStr(() => pass.logMsg(`requestCache\n\tGot ${imageElements.length} <img> elements to populate.`)));
|
||||
l.log(l.t(() => l.msg("Plugin:requestCache", "\n\t", `Got ${imageElements.length} <img> elements to populate.`)));
|
||||
if (imageElements.length == 0) {
|
||||
pass.log(pass.abortLogMsg());
|
||||
l.log(l.abortMsg("nothing to do"));
|
||||
return;
|
||||
}
|
||||
|
||||
await this.cacheManager.checkIfMetadataFileChangedExternally();
|
||||
|
||||
const requestGroups = groupRequests(imageElements, this.cacheManager, pass);
|
||||
|
|
@ -322,7 +395,7 @@ export default class ComeDownPlugin extends Plugin {
|
|||
// First try to get src from local cache.
|
||||
for (const requestGroup of requestGroups) {
|
||||
const existingCacheResult = await this.cacheManager.existingCache(requestGroup.request, true);
|
||||
pass.log(Env.dev.thunkedStr(() => pass.logMsg(`requestCache: ${existingCacheResult.item ? "Found" : "Did not find"} key ${existingCacheResult.cacheKey}. ${existingCacheResult.fileExists === undefined ? "Unknown if file exists." : `${existingCacheResult.fileExists ? "File exists." : "File does not exist."}`}`)));
|
||||
l.log(l.t(() => l.msg(`Plugin:requestCache: ${existingCacheResult.item ? "Found" : "Did not find"} key ${existingCacheResult.cacheKey}. ${existingCacheResult.fileExists === undefined ? "Unknown if file exists." : `${existingCacheResult.fileExists ? "File exists." : "File does not exist."}`}`)));
|
||||
if (existingCacheResult.item)
|
||||
await handleRequestGroup(existingCacheResult.item, requestGroup);
|
||||
};
|
||||
|
|
@ -331,12 +404,20 @@ export default class ComeDownPlugin extends Plugin {
|
|||
let numberOfRemainingDownloads = remainingRequestGroups.length;
|
||||
|
||||
if (numberOfRemainingDownloads == 0) {
|
||||
pass.log(pass.logMsg("requestCache: All images were in cache. Done."));
|
||||
pass.end(this.cacheManager);
|
||||
l.log(l.msg("Plugin:requestCache: All images were in cache. Done."));
|
||||
pass.end(this.cacheManager); // Note: Must be called even though no cache additions were made to handle possible cache removals.
|
||||
return;
|
||||
}
|
||||
|
||||
// If we are here, some images need to be downloaded.
|
||||
if (pass.ctx.isCacheAccessReadOnly) {
|
||||
l.log(l.abortMsg("Plugin:requestCache: Pass is read only. ", l.t(() => `Got ${requestGroups.length - numberOfRemainingDownloads} image from cache. ${numberOfRemainingDownloads} remaining.`)));
|
||||
|
||||
remainingRequestGroups.forEach((requestGroup) =>
|
||||
requestGroup.imageElements.forEach(el =>
|
||||
HtmlAssistant.setFailed(el, true)));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Show download notice
|
||||
if (this.settingsManager.settings.noticeOnDownload) {
|
||||
|
|
@ -368,7 +449,7 @@ export default class ComeDownPlugin extends Plugin {
|
|||
|
||||
if (result.item) {
|
||||
const resultItem = result.item;
|
||||
pass.log(Env.dev.thunkedStr(() => pass.logMsg(`requestCache:\n\tSettings src on ${imageElements.length} images\n\t${resultItem.metadata.f.n}\n\t${pass.isInPostProcessingPass ? `In HTML post processor` : `In edit listener`}`)));
|
||||
l.log(Env.dev.thunkedStr(() => l.msg(`Plugin:requestCache:\n\tSettings src on ${imageElements.length} images\n\t${resultItem.metadata.f.n}\n\t${pass.ctx.ppCtx ? `In HTML post processor` : `In edit listener`}`)));
|
||||
if (result.fileExists === true)
|
||||
await handleRequestGroup(resultItem, requestGroup);
|
||||
else
|
||||
|
|
@ -378,10 +459,10 @@ export default class ComeDownPlugin extends Plugin {
|
|||
imageElements.forEach((imageElement) => HtmlAssistant.setInvalid(imageElement));
|
||||
|
||||
if (Env.isDev && (result.error instanceof CacheFetchError || result.error instanceof CacheTypeError))
|
||||
pass.log(pass.logMsg(`requestCache: ${result.error.name}`), result.error);
|
||||
l.log(l.msg(`Plugin:requestCache: ${result.error.name}`), result.error);
|
||||
|
||||
if (!(result.error instanceof CacheFetchError || result.error instanceof CacheTypeError))
|
||||
Env.log.e("requestCache:\n\tFailed to fetch cache", result.error);
|
||||
Env.log.e("Plugin:requestCache:\n\tFailed to fetch cache", result.error);
|
||||
}
|
||||
|
||||
if (numberOfRemainingDownloads == 0) {
|
||||
|
|
@ -417,7 +498,7 @@ export default class ComeDownPlugin extends Plugin {
|
|||
requestGroup.cacheFileFound = errorResult ? false : true;
|
||||
|
||||
if (requestGroup.cacheFileFound) {
|
||||
pass.log(Env.dev.thunkedStr(() => pass.logMsg(`requestCache:handleRequestGroup: Found cached image: ${CacheManager.createCacheKeyFromMetadata(cacheItem.metadata)}, ID ${pass.passID} 📦📦📦`)));
|
||||
l.log(l.t(() => l.msg(`requestCache:handleRequestGroup: Found cached image: ${CacheManager.createCacheKeyFromMetadata(cacheItem.metadata)} 📦📦📦`)));
|
||||
pass.retainCache(requestGroup.request);
|
||||
}
|
||||
}
|
||||
|
|
@ -429,10 +510,10 @@ export default class ComeDownPlugin extends Plugin {
|
|||
* - Retain info that could be overwritten while making the request to be able to restore it afterwards.
|
||||
*
|
||||
* @param imageElements
|
||||
* @param processingPass
|
||||
* @param pass
|
||||
* @returns
|
||||
*/
|
||||
function groupRequests(imageElements: HTMLImageElement[], cacheManager: CacheManager, processingPass: ProcessingPass) {
|
||||
function groupRequests(imageElements: HTMLImageElement[], cacheManager: CacheManager, pass: ProcessingPass) {
|
||||
|
||||
const groupedByRequest: Record<string, RequestGroup> = {};
|
||||
|
||||
|
|
@ -451,7 +532,7 @@ export default class ComeDownPlugin extends Plugin {
|
|||
group.altAttributeValues.push(alt);
|
||||
}
|
||||
else {
|
||||
const request = CacheManager.createRequest(src, processingPass.associatedFile.path);
|
||||
const request = pass.ctx.isCacheAccessReadWrite() ? CacheManager.createRequest(src, pass.ctx.associatedFile.path) : CacheManager.createReadOnlyRequest(src);
|
||||
const validationError = cacheManager.validateRequest(request);
|
||||
if (validationError) {
|
||||
Env.log.e(`Cache request failed validation.`, validationError)
|
||||
|
|
|
|||
67
src/processing/EditorViewPlugin.ts
Normal file
67
src/processing/EditorViewPlugin.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { EditorView, ViewUpdate } from "@codemirror/view";
|
||||
import { Env } from "Env";
|
||||
|
||||
/**
|
||||
* Custom metadata added to the {@link EditorView}.
|
||||
* An {@link EditorView} manages plugins and may destroy and create new ones.
|
||||
* Thus this metadata outlives the {@link EditorViewPlugin} from which it is fetched, see {@link EditorViewPlugin.getViewMetadata}.
|
||||
*/
|
||||
export interface EditorViewMetadata {
|
||||
requesterPath: string | null;
|
||||
//newFileDetectedAtSeqNum: number | null;
|
||||
}
|
||||
|
||||
const DEFAULT: EditorViewMetadata = {
|
||||
requesterPath: null,
|
||||
//newFileDetectedAtSeqNum: null,
|
||||
}
|
||||
|
||||
export interface EditorViewPluginInfo {
|
||||
readonly seqNum: number;
|
||||
}
|
||||
|
||||
export class EditorViewPlugin {
|
||||
|
||||
private seqNum = 0;
|
||||
|
||||
constructor(public view: EditorView) {
|
||||
Env.log.d("EditorViewPlugin:constructor");
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
//Env.log.d("EditorViewPlugin:update", this.getViewMetadata(update.view));
|
||||
}
|
||||
|
||||
postUpdate(update: ViewUpdate, handler: (update: ViewUpdate, plugin: EditorViewPlugin, info: EditorViewPluginInfo) => void) {
|
||||
Env.log.d("EditorViewPlugin:postUpdate", this.getViewMetadata(update.view));
|
||||
handler(update, this, {
|
||||
seqNum: this.seqNum++,
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
Env.log.d("EditorViewPlugin:destroy");
|
||||
}
|
||||
|
||||
public getViewMetadata(view: EditorView) {
|
||||
const v = view as EditorViewWithMetadata;
|
||||
return v[METADATA_KEY] ?? DEFAULT;
|
||||
}
|
||||
|
||||
/** Save changes immediately if you want them to be available to the next pass (which might begin before the current has finished). */
|
||||
public setViewMetadata(view: EditorView, data: EditorViewMetadata) {
|
||||
const v = view as EditorViewWithMetadata;
|
||||
v[METADATA_KEY] = data;
|
||||
}
|
||||
|
||||
public clearViewMetadata(view: EditorView) {
|
||||
const v = view as EditorViewWithMetadata;
|
||||
delete v[METADATA_KEY];
|
||||
}
|
||||
}
|
||||
|
||||
const METADATA_KEY = Symbol("EditorViewMetadata");
|
||||
|
||||
interface EditorViewWithMetadata extends EditorView {
|
||||
[METADATA_KEY]?: EditorViewMetadata;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { getIcon } from "obsidian";
|
||||
import { Env } from "./Env";
|
||||
import { Url } from "./utils/Url";
|
||||
import { Env } from "Env";
|
||||
import { ObsAssistant } from "utils/ObsAssistant";
|
||||
import { Err } from "utils/ts";
|
||||
import { Url } from "utils/Url";
|
||||
|
||||
export const enum HTMLElementCacheState {
|
||||
/** Untouched by the plugin. */
|
||||
|
|
@ -146,9 +147,9 @@ export class HtmlAssistant {
|
|||
HtmlAssistant.setIcon(imageElement, HtmlAssistant.loadingIcon);
|
||||
}
|
||||
|
||||
public static setFailed(element: HTMLImageElement) {
|
||||
public static setFailed(element: HTMLImageElement, readOnlyAccess = false) {
|
||||
HtmlAssistant.setCacheState(element, HTMLElementCacheState.CACHE_FAILED);
|
||||
HtmlAssistant.setIcon(element, HtmlAssistant.failedIcon);
|
||||
HtmlAssistant.setIcon(element, readOnlyAccess ? HtmlAssistant.readOnlyIcon : HtmlAssistant.failedIcon);
|
||||
}
|
||||
|
||||
public static setInvalid(element: HTMLImageElement) {
|
||||
|
|
@ -158,7 +159,7 @@ export class HtmlAssistant {
|
|||
|
||||
private static setCanceled(element: HTMLImageElement) {
|
||||
HtmlAssistant.setCacheState(element, HTMLElementCacheState.ORIGINAL_SRC_REMOVED);
|
||||
HtmlAssistant.setIcon(element, HtmlAssistant.emptyIcon);
|
||||
HtmlAssistant.setIcon(element, HtmlAssistant.placeholderIcon);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -170,8 +171,8 @@ export class HtmlAssistant {
|
|||
*
|
||||
* @param imageElements
|
||||
*/
|
||||
public static cancelImageLoading(imageElements: HTMLImageElement[]) {
|
||||
Env.log.d("HtmlAssistant:cancelImageLoading: Number of images:", imageElements.length);
|
||||
public static cancelImageLoadIfNeeded(imageElements: HTMLImageElement[]) {
|
||||
Env.log.d("HtmlAssistant:cancelImageLoading: Number of images to check:", imageElements.length);
|
||||
if (imageElements.length === 0)
|
||||
return;
|
||||
|
||||
|
|
@ -284,7 +285,7 @@ export class HtmlAssistant {
|
|||
if (error instanceof TypeError)
|
||||
return new HtmlAssistantFileNotFoundError(src);
|
||||
else
|
||||
return error;
|
||||
return Err.toError(error);
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
|
|
@ -296,7 +297,7 @@ export class HtmlAssistant {
|
|||
else
|
||||
return new Error(`Invalid blob URL: ${url}`);
|
||||
} catch (error) {
|
||||
return error;
|
||||
return Err.toError(error);
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
|
@ -319,10 +320,7 @@ export class HtmlAssistant {
|
|||
*/
|
||||
private static get loadingIcon(): Blob {
|
||||
if (this.loadingIconBacking === undefined) {
|
||||
const icon = getIcon("loader")
|
||||
console.assert(icon, "loader icon id not found");
|
||||
icon!.setAttribute("stroke", "#919191");
|
||||
icon!.setAttribute("stroke-width", "1");
|
||||
const icon = ObsAssistant.getIcon("image-down", { fallbackIconID: "bug" }); // "loader"
|
||||
this.loadingIconBacking = new Blob([icon!.outerHTML], { type: "image/svg+xml" });
|
||||
}
|
||||
return this.loadingIconBacking;
|
||||
|
|
@ -331,22 +329,28 @@ export class HtmlAssistant {
|
|||
|
||||
private static get failedIcon(): Blob {
|
||||
if (this.failedIconBacking === undefined) {
|
||||
const icon = getIcon("image")
|
||||
console.assert(icon, "image icon id not found");
|
||||
icon!.setAttribute("stroke", "#ff0000");
|
||||
icon!.setAttribute("stroke-width", "1");
|
||||
const icon = ObsAssistant.getIcon("image", { color: "#ff0000", fallbackIconID: "bug" }); // image-off
|
||||
this.failedIconBacking = new Blob([icon!.outerHTML], { type: "image/svg+xml" });
|
||||
}
|
||||
return this.failedIconBacking;
|
||||
}
|
||||
private static failedIconBacking?: Blob;
|
||||
|
||||
private static get emptyIcon(): Blob {
|
||||
if (this.emptyIconBacking === undefined)
|
||||
this.emptyIconBacking = new Blob(['<svg width="0" height="0" xmlns="http://www.w3.org/2000/svg"></svg>'], { type: "image/svg+xml" });
|
||||
return this.emptyIconBacking;
|
||||
private static get readOnlyIcon(): Blob {
|
||||
if (this.readOnlyIconBacking === undefined) {
|
||||
const icon = ObsAssistant.getIcon("meh"); // frown ellipsis square-check activity
|
||||
this.readOnlyIconBacking = new Blob([icon!.outerHTML], { type: "image/svg+xml" });
|
||||
}
|
||||
return this.readOnlyIconBacking;
|
||||
}
|
||||
private static emptyIconBacking?: Blob;
|
||||
private static readOnlyIconBacking?: Blob;
|
||||
|
||||
private static get placeholderIcon(): Blob {
|
||||
if (this.placeholderIconBacking === undefined)
|
||||
this.placeholderIconBacking = new Blob(['<svg width="0" height="0" xmlns="http://www.w3.org/2000/svg"></svg>'], { type: "image/svg+xml" });
|
||||
return this.placeholderIconBacking;
|
||||
}
|
||||
private static placeholderIconBacking?: Blob;
|
||||
}
|
||||
|
||||
class HtmlAssistantFileNotFoundError extends Error {
|
||||
23
src/processing/Logger.ts
Normal file
23
src/processing/Logger.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Env } from "Env";
|
||||
import { Logger as BaseLogger } from "utils/Logger";
|
||||
|
||||
export class Logger extends BaseLogger {
|
||||
|
||||
public beginMsg(...args: unknown[]) {
|
||||
if (!Env.isDev) return "";
|
||||
const joinedArgs = args.length > 0 ? `(${args.join(" ")})` : "";
|
||||
return `${this.symbol} Begin pass. ${joinedArgs} ➡️🚪 ${this.idString}`;
|
||||
}
|
||||
|
||||
public endMsg(...args: unknown[]) {
|
||||
if (!Env.isDev) return "";
|
||||
const joinedArgs = args.length > 0 ? `(${args.join(" ")})` : "";
|
||||
return `${this.symbol} End pass. Finished. ${joinedArgs} ✅🚪➡️ ${this.idString}`;
|
||||
}
|
||||
|
||||
public abortMsg(...args: unknown[]) {
|
||||
if (!Env.isDev) return "";
|
||||
const joinedArgs = args.length > 0 ? `(${args.join(" ")})` : "";
|
||||
return `${this.symbol} End pass. Aborted. ${joinedArgs} ❌🚪➡️ ${this.idString}`;
|
||||
}
|
||||
}
|
||||
385
src/processing/ProcessingContext.ts
Normal file
385
src/processing/ProcessingContext.ts
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
import { ViewUpdate } from "@codemirror/view";
|
||||
import { Env } from "Env";
|
||||
import { App, ItemView, MarkdownPostProcessorContext, MarkdownView, TFile, View } from "obsidian";
|
||||
import { Logger } from "processing/Logger";
|
||||
import { ObsAssistant, ObsViewMode } from "utils/ObsAssistant";
|
||||
import { isDescendantOrEqual } from "utils/dom";
|
||||
import { Arr } from "utils/ts";
|
||||
|
||||
export class ProcessingContext {
|
||||
|
||||
public readonly vuCtx: ViewUpdateContext | null;
|
||||
public readonly ppCtx: PostProcessorContext | null;
|
||||
public readonly logr: Logger;
|
||||
|
||||
/**
|
||||
* `<div class="workspace-leaf-content" data-type="{view type}">`
|
||||
*
|
||||
* - Will be `null` if processing outside of a {@link View}.
|
||||
* - If {@link view} is not `null`, then this value will use {@link View.containerEl} (instead of searching for it in the DOM).
|
||||
* - Might not be `null` even if {@link view} is `null`.
|
||||
*/
|
||||
public readonly viewContainerEl: HTMLElement | null;
|
||||
|
||||
/**
|
||||
* `null` if an instance of a relevant {@link View} could not be found.
|
||||
*/
|
||||
public readonly view: View | null;
|
||||
public readonly markdownView: MarkdownView | null;
|
||||
public readonly viewingMode: ObsViewMode;
|
||||
|
||||
public readonly associatedFile: TFile | null;
|
||||
|
||||
public readonly isCacheAccessReadOnly: boolean;
|
||||
|
||||
constructor(log: Logger, view: View | null, mode: ObsViewMode, viewContainerEl: HTMLElement | null, associatedFile: TFile | null, underlyingCtx: ViewUpdateContext | PostProcessorContext) {
|
||||
this.logr = log;
|
||||
|
||||
this.view = view;
|
||||
this.markdownView = view instanceof MarkdownView ? view : null;
|
||||
|
||||
this.viewingMode = mode;
|
||||
this.associatedFile = associatedFile;
|
||||
|
||||
this.vuCtx = underlyingCtx instanceof ViewUpdateContext ? underlyingCtx : null;
|
||||
this.ppCtx = underlyingCtx instanceof PostProcessorContext ? underlyingCtx : null;
|
||||
Env.dev.assert(this.vuCtx || this.ppCtx);
|
||||
|
||||
this.viewContainerEl = viewContainerEl;
|
||||
|
||||
this.isCacheAccessReadOnly = !this.isCacheAccessReadWrite();
|
||||
}
|
||||
|
||||
public static fromViewUpdate(app: App, logger: Logger, viewUpdate: ViewUpdate): ProcessingContext {
|
||||
|
||||
// <div class="workspace-leaf-content" data-type="markdown" data-mode="source"> <-- viewContainerEl / View.containerEl
|
||||
// <div class="view-content"> <-- ItemView.contentEl
|
||||
// <div class"markdown-source-view">
|
||||
// <div class="cm-editor cm-focused ͼ1 ͼ2 "> <-- ViewUpdateContext.editorEl / EditorView.dom
|
||||
// <div class="cm-scroller">
|
||||
// <div class="cm-sizer">
|
||||
// <div class="cm-contentContainer">
|
||||
// <div class="cm-content"> <-- ViewUpdateContext.contentEl / EditorView.contentDOM
|
||||
// <div dir="ltr" class="cm-line"> <-- document content starts here
|
||||
// <img src="blob:…" contenteditable="false" data-come-down-original-source="https://…" data-come-down-state="4">
|
||||
|
||||
|
||||
const contentEl = viewUpdate.view.contentDOM;
|
||||
let viewContainerEl: HTMLElement | null = null;
|
||||
|
||||
let view = ProcessingContext.tryGetViewInstance(app, contentEl);
|
||||
if (view)
|
||||
viewContainerEl = view.containerEl;
|
||||
|
||||
// No view, get the container by traversing up.
|
||||
if (viewContainerEl === null)
|
||||
viewContainerEl = Arr.firstOrNull(ObsAssistant.viewContainerEl({ element: contentEl, dir: "up" }));
|
||||
|
||||
let viewingMode = ProcessingContext.tryGetViewingMode(view ?? undefined, viewContainerEl ?? undefined);
|
||||
|
||||
let associatedFile: TFile | null = null;
|
||||
if (view !== null)
|
||||
associatedFile = ObsAssistant.getFileFromView(view);
|
||||
|
||||
return new ProcessingContext(logger, view, viewingMode, viewContainerEl, associatedFile, new ViewUpdateContext(viewUpdate, view));
|
||||
}
|
||||
|
||||
public static fromPostProcessor(app: App, logger: Logger, element: HTMLElement, postProcessorContext: MarkdownPostProcessorContext): ProcessingContext & { readonly ppCtx: PostProcessorContext } {
|
||||
|
||||
// <div class="workspace-leaf-content" data-type="markdown" data-mode="preview"> <-- `containerEl`
|
||||
// <div class="view-content"> <--
|
||||
// <div class"markdown-reading-view">
|
||||
// <div class="markdown-preview-view markdown-renderered"> <--
|
||||
// <div class="markdown-preview-sizer markdown-preview-section">
|
||||
// <div class="markdown-preview-pusher">
|
||||
// <div class="mod-header mod-ui">
|
||||
// <div class="el-pre mod-frontmatter mod-ui">
|
||||
// <div class="el-h1"> <-- document content starts here
|
||||
// <div class="el-p">
|
||||
// <div>
|
||||
// <img src="blob:…" contenteditable="false" data-come-down-original-source="https://…" data-come-down-state="4">
|
||||
|
||||
let viewContainerEl: HTMLElement | null = null;
|
||||
|
||||
let view = ProcessingContext.tryGetViewInstance(app, element);
|
||||
if (view)
|
||||
viewContainerEl = view.containerEl;
|
||||
|
||||
// No view, get the container.
|
||||
if (viewContainerEl === null)
|
||||
viewContainerEl = Arr.firstOrNull(ObsAssistant.viewContainerEl({ element: element, postProcessorContext: postProcessorContext, dir: "up" }));
|
||||
|
||||
let viewingMode = ProcessingContext.tryGetViewingMode(view ?? undefined, viewContainerEl ?? undefined);
|
||||
// If there's a MarkdownPostProcessorContext the markdown has already been processed so must be read only…
|
||||
if (viewingMode === "none")
|
||||
viewingMode = "reader";
|
||||
|
||||
// When processing markdown, there should be a source file from which the markdown came from.
|
||||
// If `MarkdownRenderer.render` lead to this processing pass `context.sourcePath` is what was passed to that method. Perhaps invalid paths are not validated.
|
||||
let associatedFile: TFile | null = app.vault.getFileByPath(postProcessorContext.sourcePath);
|
||||
|
||||
// Fallback
|
||||
// For example, if a call to `MarkdownRenderer.render`, which takes a `sourcePath`, caused this post processing, then `MarkdownPostProcessorContext.sourcePath` is set to whatever the caller passed to `render`. Perhaps there's no check for the existance of an actual file for that path.
|
||||
if (associatedFile === null && view !== null)
|
||||
associatedFile = ObsAssistant.getFileFromView(view);
|
||||
|
||||
const ctx = new ProcessingContext(logger, view, viewingMode, viewContainerEl, associatedFile, new PostProcessorContext(element, postProcessorContext));
|
||||
return ctx as ProcessingContext & { readonly ppCtx: PostProcessorContext };
|
||||
}
|
||||
|
||||
public static logInit(ctx: ProcessingContext) {
|
||||
if (!Env.isDev)
|
||||
return ctx;
|
||||
|
||||
const l = ctx.logr;
|
||||
|
||||
if (ctx.view !== null) {
|
||||
l.log(l.msg("\t", "Found `View` instance of class:", ObsAssistant.viewClassTypeAsSting(ctx.view) + " / " + ctx.view.getViewType()));
|
||||
}
|
||||
else {
|
||||
if (ctx.viewContainerEl)
|
||||
l.log(l.msg("\t", "Did not find `View` instance, but found view container element:", ctx.viewContainerEl.classList.value));
|
||||
else {
|
||||
l.log(l.msg("\t", "Did not find `View` instance nor a view container element"));
|
||||
|
||||
const ctxEl = ctx.ppCtx?.mdCtx ? ObsAssistant.containerElFromPostProcessorContext(ctx.ppCtx.mdCtx) : null;
|
||||
if (ctxEl)
|
||||
l.log(l.msg("\t", "Found a container element in the post processing context", ctxEl.classList.value));
|
||||
}
|
||||
}
|
||||
|
||||
l.log(l.msg("\t", "Primary element", (ctx.ppCtx ? ctx.ppCtx.element : ctx.vuCtx!.contentEl).classList.value));
|
||||
l.log(l.msg("\t", "View mode:", ctx.viewingMode, ", File:", ctx.associatedFile?.basename ?? "No associated file"));
|
||||
|
||||
if (ctx.vuCtx && ctx.viewingMode === "preview") {
|
||||
const u = ctx.vuCtx.viewUpdates();
|
||||
const changed = Object.keys(u).filter(key => u[key]);
|
||||
const notChanged = Object.keys(u).filter(key => !u[key]);
|
||||
|
||||
ctx.logr.log("\t", `${changed.length} view updates:`);
|
||||
if (changed.length > 0)
|
||||
ctx.logr.log("\t", `🟢 ${changed.join(", ")}`);
|
||||
if (changed.length > 0 && notChanged.length > 0)
|
||||
ctx.logr.log("\t", `🔴 ${notChanged.join(", ")}`);
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
public assertViewUpdateContext(): asserts this is this & { readonly vuCtx: ViewUpdateContext } {
|
||||
if (!this.vuCtx)
|
||||
throw new Error("Not in edit update context.");
|
||||
}
|
||||
|
||||
public assertPostProcessor(): asserts this is this & { readonly ppCtx: PostProcessorContext } {
|
||||
if (!this.ppCtx)
|
||||
throw new Error("Not in markdown post processing context.");
|
||||
}
|
||||
|
||||
/** @returns `true` if if the requestor/retainer path is available. */
|
||||
public isCacheAccessReadWrite(): this is this & { readonly associatedFile: TFile } {
|
||||
// Determine whether it is read write here.
|
||||
return this.associatedFile !== null;
|
||||
}
|
||||
|
||||
public get isInPostProcessingPass() {
|
||||
return this.ppCtx !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @remarks The workspace's active view, if any, is not necessarily the where to current processing occurs.
|
||||
*
|
||||
* @param element The view container (or a child element) which content is currently being processed.
|
||||
* @returns The active {@link View} instance if its `containerEl` is equal to {@link element}
|
||||
*/
|
||||
private static tryGetViewInstance(app: App, element: HTMLElement) {
|
||||
const activeView = ObsAssistant.getActiveView(app);
|
||||
return activeView !== null && isDescendantOrEqual(activeView.containerEl, element) ? activeView : null;
|
||||
}
|
||||
|
||||
/** Will try by using {@link view} first; if fails, will try {@link viewContainerEl}. */
|
||||
private static tryGetViewingMode(view?: View, viewContainerEl?: HTMLElement) {
|
||||
let viewMode: ObsViewMode = "none";
|
||||
|
||||
if (view)
|
||||
viewMode = ObsAssistant.viewMode(view);
|
||||
|
||||
if (viewMode === "none" && viewContainerEl)
|
||||
viewMode = ObsAssistant.viewModeFromContainerEl(viewContainerEl);
|
||||
|
||||
return viewMode;
|
||||
}
|
||||
|
||||
public getPreferredContainerElFromViewUpdate(): HTMLElement {
|
||||
const el = this.getPreferredContainerEl();
|
||||
if (el === null)
|
||||
throw new Error("Expected non-null container element");
|
||||
return el;
|
||||
}
|
||||
|
||||
public getPreferredContainerEl() {
|
||||
Env.log.d(this.logr.t(() => this.logr.msg(`ProcessingContext:getPreferredContainerEl: is post processor: ${this.isInPostProcessingPass}; viewingMode ${this.viewingMode}; has viewContanerEl: ${this.viewContainerEl ? true : false}`)));
|
||||
|
||||
if (this.vuCtx)
|
||||
return this.vuCtx.contentEl;
|
||||
|
||||
this.assertPostProcessor();
|
||||
const pp = this.ppCtx;
|
||||
const viewOrUndefined = this.view ?? undefined;
|
||||
let el: HTMLElement | null = null;
|
||||
|
||||
// Check if reading view container is available first, this is the primary reading view container used in, e.g., tabs.
|
||||
// A popover, for example, do not have a reading view.
|
||||
|
||||
if (pp.isElementAttached()) {
|
||||
el = Arr.firstOrNull(ObsAssistant.readingViewEl({ element: pp.element, dir: "up" }));
|
||||
el = el ?? Arr.firstOrNull(ObsAssistant.viewContentEl({ element: pp.element, dir: "up" }));
|
||||
el = el ?? Arr.firstOrNull(ObsAssistant.popoverEl({ element: pp.element, dir: "up" }));
|
||||
el = el ?? Arr.firstOrNull(ObsAssistant.previewViewEl({ element: pp.element, dir: "up", allDescendants: false }));
|
||||
}
|
||||
else {
|
||||
if (this.viewContainerEl)
|
||||
el = Arr.firstOrNull(ObsAssistant.readingViewEl({ element: this.viewContainerEl, dir: "down" }));
|
||||
|
||||
if (el === null) {
|
||||
if (this.view instanceof ItemView)
|
||||
el = this.view.contentEl;
|
||||
else if (this.viewContainerEl)
|
||||
el = Arr.firstOrNull(ObsAssistant.viewContentEl({ element: this.viewContainerEl, dir: "down" }));
|
||||
}
|
||||
|
||||
// Ex: Hover over a note in file explorer to display a popover preview where images are outside of the viewport so their HTML chunks are not connected to the document.
|
||||
if (el === null)
|
||||
el = Arr.firstOrNull(ObsAssistant.popoverEl({ view: viewOrUndefined, postProcessorContext: pp.mdCtx, dir: "updown" }));
|
||||
}
|
||||
|
||||
// Do the utmost to find a preview view
|
||||
if (el === null) {
|
||||
el = Arr.firstOrNull(ObsAssistant.previewViewEl({
|
||||
dir: "updown",
|
||||
element: pp.element,
|
||||
view: viewOrUndefined,
|
||||
postProcessorContext: pp.mdCtx,
|
||||
allDescendants: false,
|
||||
}));
|
||||
}
|
||||
|
||||
if (Env.isDev && el !== null && this.viewingMode === "reader") {
|
||||
const sourceView = Arr.firstOrNull(ObsAssistant.sourceViewEl({ element: el, dir: "down" }));
|
||||
if (sourceView)
|
||||
Env.log.w("ProcessingContext:preferredEl:", "Reader has access to source view.");
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
public domEquals(other: ProcessingContext): boolean {
|
||||
Env.assert(this.vuCtx !== null && other.vuCtx !== null || this.ppCtx !== null && other.ppCtx !== null);
|
||||
let eq = true;
|
||||
|
||||
if (this.vuCtx !== null && other.vuCtx !== null)
|
||||
eq = this.vuCtx.equalsDom(other.vuCtx);
|
||||
else if (this.ppCtx !== null && other.ppCtx !== null)
|
||||
eq = this.ppCtx.equalsDom(other.ppCtx);
|
||||
else
|
||||
Env.assert();
|
||||
|
||||
if (eq)
|
||||
eq = this.viewContainerEl === other.viewContainerEl;
|
||||
|
||||
return eq;
|
||||
}
|
||||
}
|
||||
|
||||
class ViewUpdateContext {
|
||||
|
||||
public readonly viewUpdate: ViewUpdate;
|
||||
public readonly view: View | null;
|
||||
|
||||
/** <div class="cm-editor"> */
|
||||
public readonly editorEl: HTMLElement;
|
||||
|
||||
/** `<div class="cm-content">` */
|
||||
public readonly contentEl: HTMLElement;
|
||||
|
||||
//public readonly state: EditUpdateState;
|
||||
|
||||
constructor(viewUpdate: ViewUpdate, view: View | null) {
|
||||
this.viewUpdate = viewUpdate;
|
||||
this.view = view;
|
||||
|
||||
this.editorEl = viewUpdate.view.dom;
|
||||
this.contentEl = viewUpdate.view.contentDOM;
|
||||
|
||||
//this.state = EditUpdateState.get(viewUpdate);
|
||||
}
|
||||
|
||||
public equalsDom(other: ViewUpdateContext): boolean {
|
||||
return this.editorEl === other.editorEl && this.contentEl === other.contentEl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Never seems to become more specific / go deeper than the {@link contentEl}.
|
||||
* Will be equal to {@link contentEl} when cursor is below the frontmatter.
|
||||
*/
|
||||
public get activeEl(): Element | null {
|
||||
return this.viewUpdate.view.root.activeElement;
|
||||
}
|
||||
|
||||
public get isContentElActive() {
|
||||
return isDescendantOrEqual(this.contentEl, this.activeEl);
|
||||
}
|
||||
|
||||
public get hasUpdates() {
|
||||
const vu = this.viewUpdate;
|
||||
return vu.docChanged || vu.viewportChanged || vu.selectionSet || vu.focusChanged || vu.geometryChanged || vu.heightChanged || vu.viewportMoved;
|
||||
}
|
||||
|
||||
public viewUpdates(): Record<string, boolean> {
|
||||
return {
|
||||
docChanged: this.viewUpdate.docChanged,
|
||||
viewportChanged: this.viewUpdate.viewportChanged,
|
||||
selectionSet: this.viewUpdate.selectionSet,
|
||||
focusChanged: this.viewUpdate.focusChanged,
|
||||
geometryChanged: this.viewUpdate.geometryChanged,
|
||||
heightChanged: this.viewUpdate.heightChanged,
|
||||
viewportMoved: this.viewUpdate.viewportMoved,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class PostProcessorContext {
|
||||
public readonly mdCtx: MarkdownPostProcessorContext;
|
||||
|
||||
/** The HTML part that is currently being processed. Likely not attached to the DOM. */
|
||||
public readonly element: HTMLElement;
|
||||
|
||||
constructor(element: HTMLElement, markdownPostProcessorContext: MarkdownPostProcessorContext) {
|
||||
this.element = element;
|
||||
this.mdCtx = markdownPostProcessorContext;
|
||||
}
|
||||
|
||||
public equals(other: PostProcessorContext): boolean {
|
||||
if (this === other) return true;
|
||||
|
||||
if (!this.equalsDom(other)) return false;
|
||||
if (this.mdCtx.sourcePath !== other.mdCtx.sourcePath) return false;
|
||||
if (this.mdCtx.docId !== other.mdCtx.docId) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public equalsDom(other: PostProcessorContext): boolean {
|
||||
return this.element === other.element;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param parent Specify to check whether {@link element} is attached to this specific parent. If `null` method returns false.
|
||||
* @returns `true` if {@link element} is "connected (directly or indirectly) to a `Document` object".
|
||||
*/
|
||||
public isElementAttached(parent?: HTMLElement | null) {
|
||||
if (parent === null || !this.element.isConnected)
|
||||
return false;
|
||||
return parent === undefined || isDescendantOrEqual(parent, this.element);
|
||||
}
|
||||
}
|
||||
321
src/processing/ProcessingPass.ts
Normal file
321
src/processing/ProcessingPass.ts
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import { CacheManager, CacheRequest } from "cache/CacheManager";
|
||||
import { Env } from "Env";
|
||||
import { EditorViewPlugin, EditorViewPluginInfo } from "processing/EditorViewPlugin";
|
||||
import { HtmlAssistant, HTMLElementCacheState } from "processing/HtmlAssistant";
|
||||
import { Logger } from "processing/Logger";
|
||||
import { ProcessingContext } from "processing/ProcessingContext";
|
||||
import { Workarounds } from "processing/Workarounds";
|
||||
import { CodeMirrorAssistant } from "utils/CodeMirrorAssistant";
|
||||
import { ObsViewMode } from "utils/ObsAssistant";
|
||||
import { Url } from "utils/Url";
|
||||
|
||||
type FetchedSrc = string | null;
|
||||
|
||||
export class ProcessingPass {
|
||||
|
||||
public readonly ctx: ProcessingContext;
|
||||
|
||||
public constructor(ctx: ProcessingContext) {
|
||||
Env.dev.assert(ctx.vuCtx !== null || ctx.ppCtx !== null);
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
public static createViewUpdateLogger() {
|
||||
return new Logger(Env.log.edit, this.updatePassID++, Env.dev.icon.EDIT_UPDATE_PASS);
|
||||
}
|
||||
private static updatePassID: number = 0;
|
||||
|
||||
public static createPostProcessorLogger() {
|
||||
return new Logger(Env.log.read, this.postProcessorPassID++, Env.dev.icon.POST_PROCESS_PASS);
|
||||
}
|
||||
private static postProcessorPassID: number = 0;
|
||||
|
||||
public end(cacheManager: CacheManager) {
|
||||
this.ctx.logr.log(this.ctx.logr.endMsg("ReadOnly:", !this.ctx.isCacheAccessReadWrite()));
|
||||
|
||||
if (this.ctx.isCacheAccessReadWrite()) {
|
||||
const options = {
|
||||
preventReleases: this.ctx.isInPostProcessingPass,
|
||||
requestsToIgnore: this.requestsToIgnore,
|
||||
logger: this.ctx.logr,
|
||||
};
|
||||
|
||||
cacheManager.updateRetainedCaches(Object.values(this.requestsToRetain), this.ctx.associatedFile.path, options).then(() => {
|
||||
this.ctx.logr.log(this.ctx.logr.msg("\tCalling saveMetadataIfDirty"));
|
||||
cacheManager.saveMetadataIfDirty();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static nextPostProcessorPassID() {
|
||||
return this.postProcessorPassID++;
|
||||
}
|
||||
|
||||
public retainCache(urlOrCache: string | CacheRequest) {
|
||||
if (this.ctx.isCacheAccessReadWrite()) {
|
||||
Env.assert(urlOrCache);
|
||||
this.ctx.logr.log(this.ctx.logr.t(() => this.ctx.logr.msg(`retainCache: Will retain ${CacheManager.createCacheKeyFromOriginalSrc(Env.str.is(urlOrCache) ? urlOrCache : urlOrCache.source)} at the end of pass`)));
|
||||
|
||||
if (Env.str.is(urlOrCache)) {
|
||||
Env.assert(urlOrCache.length > 0);
|
||||
this.requestsToRetain[urlOrCache] = CacheManager.createRequest(urlOrCache, this.ctx.associatedFile.path);
|
||||
}
|
||||
else {
|
||||
this.requestsToRetain[urlOrCache.source] = urlOrCache;
|
||||
}
|
||||
}
|
||||
}
|
||||
private requestsToRetain: Record<string, CacheRequest> = {};
|
||||
|
||||
public ignoreCache(src: string) {
|
||||
if (this.ctx.isCacheAccessReadWrite()) {
|
||||
Env.assert(src);
|
||||
this.ctx.logr.log(this.ctx.logr.t(() => this.ctx.logr.msg(`ignoreCache: Will ignore ${CacheManager.createCacheKeyFromOriginalSrc(src)} at the end of pass`)));
|
||||
|
||||
this.requestsToIgnore.add(CacheManager.createRequest(src, this.ctx.associatedFile.path));
|
||||
}
|
||||
}
|
||||
private requestsToIgnore = new Set<CacheRequest>();
|
||||
|
||||
/**
|
||||
* `currentDOM` only includes nodes visible in the viewport (plus a margin).
|
||||
* This method makes sure the remaining images in the note are retained to prevent them from being deleted.
|
||||
*
|
||||
* @param imageElementsInDom Images in viewport/DOM returned by {@link findRelevantImagesToProcessViewUpdate} that have **already been canceled**.
|
||||
* @returns
|
||||
*/
|
||||
public handleImagesNotInCurrentDOM(imageElementsInDom: HTMLImageElement[]) {
|
||||
Env.log.d("ProcessingPass:handleImagesNotInCurrentDOM");
|
||||
this.ctx.assertViewUpdateContext();
|
||||
|
||||
const imgSrcInDom = imageElementsInDom.map(el => {
|
||||
Env.dev.assert(HtmlAssistant.cacheState(el) !== HTMLElementCacheState.ORIGINAL);
|
||||
return HtmlAssistant.originalSrc(el, false);
|
||||
});
|
||||
|
||||
const imagesOutsideViewport = CodeMirrorAssistant.findAllImages(this.ctx.vuCtx.viewUpdate.view, (url) => {
|
||||
const normalizedUrl = Url.normalizeUrl(url);
|
||||
if (imgSrcInDom.includes(normalizedUrl))
|
||||
return false;
|
||||
return Url.isValidExternalUrl(normalizedUrl); // These urls might be local.
|
||||
});
|
||||
|
||||
this.ctx.logr.log(this.ctx.logr.t(() => this.ctx.logr.msg("Found " + imagesOutsideViewport?.length + " images outside viewport: " + imagesOutsideViewport?.map(f => f.src).join(", "))));
|
||||
imagesOutsideViewport?.forEach(image => this.retainCache(image.src));
|
||||
}
|
||||
|
||||
public static findRelevantImagesToProcessInPostProcessor(element: HTMLElement | null, requireSrcAttribute: boolean): { elementsToProcess: HTMLImageElement[], remainingElements: HTMLImageElement[] } {
|
||||
const remainingElements: HTMLImageElement[] = [];
|
||||
const elementsToProcess: HTMLImageElement[] = [];
|
||||
|
||||
if (element !== null) {
|
||||
const imageElements = HtmlAssistant.findAllImageElements(element, requireSrcAttribute);
|
||||
const imagesToCancelFilter = (imageElement: HTMLImageElement, availableSrc?: FetchedSrc) => {
|
||||
|
||||
const src = ProcessingPass.src(imageElement, availableSrc);
|
||||
|
||||
// Edge case, e.g.: `<img>`
|
||||
if (src === null && HtmlAssistant.cacheState(imageElement) === HTMLElementCacheState.ORIGINAL)
|
||||
return false;
|
||||
|
||||
return ProcessingPass.filterIrrelevantCacheStates(imageElement) || HtmlAssistant.isImageToProcess(imageElement);
|
||||
};
|
||||
|
||||
for (const imageElement of imageElements) {
|
||||
const src = ProcessingPass.src(imageElement);
|
||||
if (imagesToCancelFilter(imageElement, src))
|
||||
elementsToProcess.push(imageElement);
|
||||
else if (src !== null)
|
||||
remainingElements.push(imageElement);
|
||||
}
|
||||
}
|
||||
|
||||
return { elementsToProcess, remainingElements };
|
||||
}
|
||||
|
||||
public static findRelevantImagesToProcessViewUpdate(ctx: ProcessingContext): { elementsToProcess: HTMLImageElement[], remainingElements: HTMLImageElement[] } {
|
||||
ctx.assertViewUpdateContext();
|
||||
|
||||
// Elements in DOM at this stage might be in states in which the `src` attribute has been removed. Therefore the `src` attribute is not required when finding image elements.
|
||||
const imageElements = HtmlAssistant.findAllImageElements(ctx.getPreferredContainerElFromViewUpdate(), false);
|
||||
const sourcesToIgnore = Workarounds.detectSourcesOfInvalidImageElements(ctx.vuCtx.viewUpdate);
|
||||
|
||||
const elementsToProcess: HTMLImageElement[] = [];
|
||||
const remainingElements: HTMLImageElement[] = [];
|
||||
|
||||
const imagesToCancelFilter = (imageElement: HTMLImageElement, availableSrc?: FetchedSrc) => {
|
||||
// This filter should work regardless of whether the `src` attr. has been removed, or set to an icon, or is empty.
|
||||
// Make jugements based on state.
|
||||
const src = ProcessingPass.src(imageElement, availableSrc);
|
||||
|
||||
// Edge case, e.g.: `<img>`
|
||||
if (src === null && HtmlAssistant.cacheState(imageElement) === HTMLElementCacheState.ORIGINAL)
|
||||
return false;
|
||||
|
||||
// 2. If there's no src there's nothing left to do but to remove all states that have passed this stage already.
|
||||
if (src === null)
|
||||
return ProcessingPass.filterIrrelevantCacheStates(imageElement);
|
||||
|
||||
// 3. Check if this image element should be ignored.
|
||||
if (!Workarounds.handleInvalidImageElements(sourcesToIgnore, imageElement, src))
|
||||
return false;
|
||||
|
||||
// 3. Only external urls are relevant.
|
||||
// If not negating and returning false, local urls won't be cached here
|
||||
if (!HtmlAssistant.isImageToProcess(imageElement))
|
||||
return false;
|
||||
|
||||
// 4. At this stage filtering based on urls should be done, and here, just look at states to remove all states that have passed this stage already.
|
||||
return ProcessingPass.filterIrrelevantCacheStates(imageElement);
|
||||
};
|
||||
|
||||
for (const imageElement of imageElements) {
|
||||
|
||||
const src = ProcessingPass.src(imageElement);
|
||||
|
||||
// 1. First check if a new URL was set on an existing element so that it will be canceled.
|
||||
ProcessingPass.checkIfUrlChanged(ctx, imageElement, src);
|
||||
|
||||
if (imagesToCancelFilter(imageElement, src))
|
||||
elementsToProcess.push(imageElement);
|
||||
else if (src !== null)
|
||||
remainingElements.push(imageElement);
|
||||
}
|
||||
|
||||
return { elementsToProcess, remainingElements };
|
||||
}
|
||||
|
||||
private static src = (imageElement: HTMLImageElement, availableSrc?: FetchedSrc) => availableSrc === undefined ? HtmlAssistant.getSrc(imageElement) : availableSrc;
|
||||
|
||||
/**
|
||||
* Detects whether the url of an already existing image elemetent was changed by the user, and if so, treats the image element as newly added.
|
||||
*
|
||||
* - Should be initial processing step in the editor listener (or at least before canceling starts).
|
||||
* - Note: This must be the first thing that happens since the state is reset, i.e., it effectively aborts any actions that would've been taken on other states.
|
||||
* - That said, the previous element might have been deleted by the system and a new element created, in which case this method does nothing.
|
||||
*/
|
||||
private static checkIfUrlChanged(ctx: ProcessingContext, imageElement: HTMLImageElement, availableSrc?: FetchedSrc) {
|
||||
ctx.assertViewUpdateContext();
|
||||
|
||||
const src = ProcessingPass.src(imageElement, availableSrc);
|
||||
const changed = ctx.vuCtx.viewUpdate.docChanged && HtmlAssistant.cacheState(imageElement) != HTMLElementCacheState.ORIGINAL && Url.isValidExternalUrl(src);
|
||||
|
||||
Env.log.d(ctx.logr.msg("ProcessingPass:checkIfUrlChanged", changed));
|
||||
|
||||
// These all come together to reveal that the src has changed and thus is treated as a new image.
|
||||
// - `update.docChanged`: user edited
|
||||
// - `HtmlAssistant.cacheState(imageElement) != HTMLElementCacheState.ORIGINAL`: Only unprocessed/"original" image elements have `src` set to external urls...
|
||||
// - `Logic.isValidExternalUr`: ...but this one *has* such url.
|
||||
// Therefore the src was modified, which is tantamount to a separate, added image element, so the state need to be reset and it will be treated as such.
|
||||
// The cache reference will be released as it is not retained now.
|
||||
|
||||
if (changed) {
|
||||
ctx.logr.log(ctx.logr.t(() => ctx.logr.msg(`Url changed on image from ${HtmlAssistant.originalSrc(imageElement)} to ${src}`)));
|
||||
HtmlAssistant.resetElement(imageElement); // Set state to "untouched".
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove elements with states: requesting, downloading, done, and invalid.
|
||||
*
|
||||
* - Those done are already pointing to the cached resource. No need to do anything more.
|
||||
* - Those that are downloading will be handled as the download finishes as part of a previous pass.
|
||||
*
|
||||
* Keeps (returns `true` for)
|
||||
*
|
||||
* - Original: these need to be cancelled
|
||||
* - Cancelled and Failed: these need to request cache.
|
||||
*
|
||||
* **This method does not touch the `src` attribute, it only looks at the states.**
|
||||
*
|
||||
* @param imageElement
|
||||
* @returns `false` if state of {@link imageElement} is one of the above mentioned irrelevant states.
|
||||
*/
|
||||
public static filterIrrelevantCacheStates(imageElement: HTMLImageElement) {
|
||||
Env.log.d("ProcessingPass:filterIrrelevantCacheStates");
|
||||
const state = HtmlAssistant.cacheState(imageElement)
|
||||
|
||||
// return HtmlAssistant.isCacheStateEqual(state, [HTMLElementCacheState.ORIGINAL, HTMLElementCacheState.ORIGINAL_SRC_REMOVED, HTMLElementCacheState.CACHE_FAILED]);
|
||||
|
||||
// Difference between this and the above is that ORIGINAL matches all unprocessed elements.
|
||||
return !HtmlAssistant.isCacheStateEqual(state,
|
||||
HTMLElementCacheState.REQUESTING,
|
||||
HTMLElementCacheState.REQUESTING_DOWNLOADING,
|
||||
HTMLElementCacheState.CACHE_SUCCEEDED,
|
||||
HTMLElementCacheState.INVALID
|
||||
);
|
||||
}
|
||||
|
||||
/** Makes sure that elements requesting cache, waiting for download, or already cached, are not released when {@link end} is called. */
|
||||
public handleRequestingAndSucceeded(imageElements: HTMLImageElement[]) {
|
||||
Env.log.d("ProcessingPass:handleRequestingAndSucceeded:", imageElements);
|
||||
|
||||
for (const imageElement of imageElements) {
|
||||
// As all images are retained in each pass, even though elements that are already cached are excluded from further processing, they still need to be retained.
|
||||
if (HtmlAssistant.cacheState(imageElement) == HTMLElementCacheState.CACHE_SUCCEEDED) {
|
||||
const src = HtmlAssistant.originalSrc(imageElement);
|
||||
Env.assert(src !== null, "Expected original source dataset");
|
||||
if (src)
|
||||
this.retainCache(src);
|
||||
}
|
||||
|
||||
if (HtmlAssistant.isElementCacheStateEqual(imageElement, HTMLElementCacheState.REQUESTING, HTMLElementCacheState.REQUESTING_DOWNLOADING)) {
|
||||
const src = HtmlAssistant.originalSrc(imageElement);
|
||||
Env.assert(src !== null, "Expected original source dataset");
|
||||
if (src)
|
||||
this.ignoreCache(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static abortIfMode(ctx: ProcessingContext, ...modes: ObsViewMode[]) {
|
||||
for (const mode of modes) {
|
||||
if (ctx.viewingMode === mode) {
|
||||
ctx.logr.log(ctx.logr.abortMsg("Viewing mode:", ctx.viewingMode));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Must be called **before any other aborts** because this method simply looks at `info.seqNum === 0` to catch the first update call (rather than using a separate bool `info.seqNum < info.newFileDetectedAtSeqNum`). */
|
||||
public static abortIfInvalidContext(ctx: ProcessingContext, plugin: EditorViewPlugin, info: EditorViewPluginInfo) {
|
||||
if (ctx.associatedFile === null) {
|
||||
ctx.logr.log(ctx.logr.msg("abortIfInvalidContext: no file, continuing as a read only pass"));
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx.assertViewUpdateContext();
|
||||
const editorView = ctx.vuCtx.viewUpdate.view;
|
||||
|
||||
const data = plugin.getViewMetadata(editorView);
|
||||
const associatedFilePath = ctx.associatedFile.path;
|
||||
const viewFilePath = data.requesterPath;
|
||||
|
||||
// A change has definitely occured in that another path was set.
|
||||
if (viewFilePath !== associatedFilePath) {
|
||||
// As view update calls are handled by CodeMirror and the file path retreived from Obsidian API,
|
||||
// the `contentDOM` of the `EditorView` might not yet have been updated to reflect the content if the new
|
||||
// file that is about to be displayed.
|
||||
// Therefore, continuing in this state would cause dom elements to be associated with the wrong file
|
||||
// in the few update events that occurs until the `contentDOM` reflects the content of the new file.
|
||||
// The results in cache items being removed and downloaded until the state stabalizes again (i.e. `contentDOM` = file content).
|
||||
|
||||
// However, each time a new file is initiated with the EditorView, the current view plugin is destroyed and a new instantiated.
|
||||
// The first view update of a fresh view plugin has been shown to contain the `contentDOM` reflecting the file.
|
||||
|
||||
if (info.seqNum === 0) {
|
||||
ctx.logr.log(ctx.logr.msg("analyzeViewUpdate: New file detected."));
|
||||
data.requesterPath = associatedFilePath;
|
||||
plugin.setViewMetadata(editorView, data);
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
ctx.logr.log(ctx.logr.msg("analyzeViewUpdate: waiting..., current seq num:", info.seqNum));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { ViewUpdate } from "@codemirror/view";
|
||||
import { Env } from "./Env";
|
||||
import { HtmlAssistant, HTMLElementAttribute, HTMLElementCacheState } from "./HtmlAssistant";
|
||||
import { Url } from "./utils/Url";
|
||||
import { Env } from "Env";
|
||||
import { HtmlAssistant, HTMLElementAttribute, HTMLElementCacheState } from "processing/HtmlAssistant";
|
||||
import { Url } from "utils/Url";
|
||||
|
||||
export class Workarounds {
|
||||
/**
|
||||
6
src/types.ts
Normal file
6
src/types.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Global (see tsconfig `include`)
|
||||
// Do not add `export`
|
||||
|
||||
type Prettify<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
} & {};
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { CacheManager } from "cache/CacheManager";
|
||||
import { Env } from "Env";
|
||||
import { App, ButtonComponent, Modal, Setting } from "obsidian";
|
||||
import { CacheManager } from "../CacheManager";
|
||||
import { PluginSettings } from "../Settings";
|
||||
import { Notice } from "./Notice";
|
||||
import { Env } from "../Env";
|
||||
import { PluginSettings } from "Settings";
|
||||
import { Notice } from "ui/Notice";
|
||||
|
||||
export class InfoModal extends Modal {
|
||||
private cacheManager: CacheManager;
|
||||
|
|
@ -67,8 +67,8 @@ export class InfoModal extends Modal {
|
|||
|
||||
debugInfoSetting?: Setting;
|
||||
clearCacheSetting: Setting;
|
||||
clearCacheButton: ButtonComponent;
|
||||
closeButton: ButtonComponent;
|
||||
clearCacheButton!: ButtonComponent;
|
||||
closeButton!: ButtonComponent;
|
||||
|
||||
onOpen(): void {
|
||||
super.onOpen();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { EditorView, ViewUpdate } from "@codemirror/view";
|
||||
import { SyntaxNodeRef, Tree } from "@lezer/common";
|
||||
import { Env } from "../Env";
|
||||
import { Env } from "Env";
|
||||
|
||||
export interface ParsedImage {
|
||||
src: string;
|
||||
|
|
@ -14,6 +14,10 @@ type UrlFilter = (url: string) => boolean;
|
|||
|
||||
export class CodeMirrorAssistant {
|
||||
|
||||
public static contentDomFromViewUpdate(viewUpdate: ViewUpdate) {
|
||||
return viewUpdate.view.contentDOM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the syntax tree currently available in the editor state covers the entire document.
|
||||
* @returns `true` if the tree is fully parsed, otherwise `false`.
|
||||
|
|
@ -79,85 +83,86 @@ export class CodeMirrorAssistant {
|
|||
tree.iterate({
|
||||
from,
|
||||
to,
|
||||
enter: (node: SyntaxNodeRef) => {
|
||||
enter: (nodeRef) => {
|
||||
const node = nodeRef.node;
|
||||
|
||||
// Frontmatter and code blocks cannot contain (rendered) images.
|
||||
if (node.name.includes(NodeName.CODEBLOCK_NODE_PART) || node.name === NodeName.FRONTMATTER_DEF)
|
||||
if (node.name.includes(NodeName.CODEBLOCK_NODE_PART) || node.name.startsWith(NodeName.FRONTMATTER_DEF)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle markdown images via tree structure (fast path)
|
||||
if (node.name === NodeName.URL) {
|
||||
const urlNode = node.node;
|
||||
// --- Markdown Image Logic ---
|
||||
if (node.name.startsWith(NodeName.IMAGE_MARKER)) {
|
||||
if (images.some(img => node.from >= img.from && node.to <= img.to)) return;
|
||||
|
||||
const closingParen = urlNode.nextSibling;
|
||||
if (closingParen?.name !== NodeName.URL_FORMATTING)
|
||||
return;
|
||||
let urlNode = null;
|
||||
let altNode = null;
|
||||
let closingParenNode = null;
|
||||
|
||||
// Extract and filter URL as soon as possible
|
||||
const rawUrl = view.state.doc.sliceString(urlNode.from, urlNode.to);
|
||||
const urlParts = rawUrl.split(RegEx.URL_SPLIT);
|
||||
const url = urlParts.length > 0 ? urlParts[0] : undefined; // Extract just the URL part, excluding possible appended titles and dimensions, e.g.,: `https://example.com/image3.gif "This is a title"`, `https://example.com/image4.jpg =300x200`, `https://example.com/image7.webp "Title text" =250x150`.
|
||||
if (url === undefined)
|
||||
return;
|
||||
let currentNode = node.nextSibling;
|
||||
while (currentNode) {
|
||||
if (currentNode.name.startsWith(NodeName.IMAGE_MARKER)) break;
|
||||
|
||||
if (filter && !filter(url))
|
||||
return;
|
||||
const isUrlNode = currentNode.name.endsWith(NodeName.URL) && !currentNode.name.includes(NodeName.FORMATTING);
|
||||
const isAltNode = currentNode.name.startsWith(NodeName.IMAGE_ALT_LINK);
|
||||
|
||||
// Pattern 1: 
|
||||
const p1 = urlNode.prevSibling; // (
|
||||
const p2 = p1?.prevSibling; // ]
|
||||
const p3 = p2?.prevSibling; // alt
|
||||
const p4 = p3?.prevSibling; // [
|
||||
const p5 = p4?.prevSibling; // !
|
||||
if (p1?.name === NodeName.URL_FORMATTING &&
|
||||
p2?.name === NodeName.IMAGE_ALT_LINK_FORMATTING &&
|
||||
p3?.name === NodeName.IMAGE_ALT_LINK &&
|
||||
p4?.name === NodeName.IMAGE_ALT_LINK_FORMATTING &&
|
||||
p5?.name === NodeName.IMAGE_MARKER) {
|
||||
const alt = view.state.doc.sliceString(p3.from, p3.to);
|
||||
images.push({ src: url, alt, from: p5.from, to: closingParen.to });
|
||||
return;
|
||||
if (!urlNode && isUrlNode) {
|
||||
urlNode = currentNode;
|
||||
} else if (!altNode && isAltNode) {
|
||||
altNode = currentNode;
|
||||
}
|
||||
|
||||
if (urlNode) {
|
||||
const isUrlFormatting = currentNode.name.startsWith(NodeName.LINK_FORMATTING) && currentNode.name.endsWith(NodeName.URL);
|
||||
const isAfterUrl = currentNode.from > urlNode.from;
|
||||
if (isUrlFormatting && isAfterUrl) {
|
||||
closingParenNode = currentNode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
currentNode = currentNode.nextSibling;
|
||||
}
|
||||
|
||||
// Pattern 2: 
|
||||
const s1 = urlNode.prevSibling; // ](
|
||||
const s2 = s1?.prevSibling; // [
|
||||
const s3 = s2?.prevSibling; // !
|
||||
if (s1?.name === NodeName.URL_FORMATTING &&
|
||||
s2?.name === NodeName.IMAGE_ALT_LINK_FORMATTING &&
|
||||
s3?.name === NodeName.IMAGE_MARKER) {
|
||||
images.push({ src: url, alt: undefined, from: s3.from, to: closingParen.to });
|
||||
return;
|
||||
if (urlNode && closingParenNode) {
|
||||
const rawUrl = view.state.doc.sliceString(urlNode.from, urlNode.to);
|
||||
const urlParts = rawUrl.split(RegEx.URL_SPLIT);
|
||||
const url = urlParts.length > 0 ? urlParts[0] : undefined;
|
||||
if (url === undefined) return;
|
||||
|
||||
if (filter && !filter(url)) return;
|
||||
|
||||
const alt = altNode ? view.state.doc.sliceString(altNode.from, altNode.to) : "";
|
||||
|
||||
images.push({ src: url, alt, from: node.from, to: closingParenNode.to });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle HTML img tags via targeted text parsing
|
||||
if (node.name === NodeName.HTML_BEGIN_TAG) {
|
||||
// Find the complete HTML tag by looking for the closing tag
|
||||
let current = node.node;
|
||||
let endNode = current.nextSibling;
|
||||
// --- HTML Image Logic ---
|
||||
if (node.name.startsWith(NodeName.HTML_BEGIN_TAG)) {
|
||||
if (images.some(img => node.from >= img.from && node.to <= img.to)) return;
|
||||
|
||||
// Navigate to find the closing bracket
|
||||
while (endNode && endNode.name !== NodeName.HTML_END_TAG)
|
||||
endNode = endNode.nextSibling;
|
||||
let endNode = null;
|
||||
let currentNode = node.nextSibling;
|
||||
while(currentNode) {
|
||||
if (currentNode.name.startsWith(NodeName.HTML_END_TAG)) {
|
||||
endNode = currentNode;
|
||||
break;
|
||||
}
|
||||
if (currentNode.name.startsWith(NodeName.HTML_BEGIN_TAG)) break;
|
||||
currentNode = currentNode.nextSibling;
|
||||
}
|
||||
|
||||
if (endNode) {
|
||||
const htmlText = view.state.doc.sliceString(current.from, endNode.to);
|
||||
|
||||
// Only process img tags
|
||||
if (htmlText.includes('<img')) {
|
||||
const srcMatch = htmlText.match(RegEx.HTML_SRC);
|
||||
const text = view.state.doc.sliceString(node.from, endNode.to);
|
||||
if (text.includes("<img")) {
|
||||
const srcMatch = text.match(RegEx.HTML_SRC);
|
||||
if (srcMatch && srcMatch[1]) {
|
||||
const url = srcMatch[1];
|
||||
if (filter && !filter(url)) return;
|
||||
|
||||
// Apply filter early
|
||||
if (filter && !filter(url))
|
||||
return;
|
||||
|
||||
const altMatch = htmlText.match(RegEx.HTML_ALT);
|
||||
const altMatch = text.match(RegEx.HTML_ALT);
|
||||
const alt = altMatch?.[1];
|
||||
|
||||
images.push({ src: url, alt, from: current.from, to: endNode.to });
|
||||
images.push({ src: url, alt, from: node.from, to: endNode.to });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -191,6 +196,8 @@ const NodeName = {
|
|||
FRONTMATTER_DEF: "def_hmd-frontmatter",
|
||||
/** All nodes related to a code block seem to have this as part of their name. Thus use `include`: `node.name.includes(NodeName.CODEBLOCK_NODE_PART`. */
|
||||
CODEBLOCK_NODE_PART: "HyperMD-codeblock",
|
||||
FORMATTING: "formatting",
|
||||
LINK_FORMATTING: "formatting_formatting-link-string",
|
||||
} as const;
|
||||
|
||||
// This eliminates the regex compilation overhead on each call. Performance gain might be small, it's still a good practice.
|
||||
|
|
|
|||
25
src/utils/Logger.ts
Normal file
25
src/utils/Logger.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Env, LoggerFn } from "Env";
|
||||
|
||||
export class Logger {
|
||||
|
||||
public readonly log: LoggerFn;
|
||||
public readonly id: number;
|
||||
public readonly symbol: string;
|
||||
public readonly t = (action: () => string) => Env.isDev ? action() : "";
|
||||
|
||||
constructor(log: LoggerFn, id: number, symbol: string) {
|
||||
this.log = log;
|
||||
this.id = id;
|
||||
this.symbol = symbol;
|
||||
}
|
||||
|
||||
public get idString() {
|
||||
return "— ID" + this.id;
|
||||
}
|
||||
|
||||
public msg(...args: unknown[]) {
|
||||
if (!Env.isDev) return "";
|
||||
const mappedArgs = args.map(arg => arg === undefined ? "undefined" : arg);
|
||||
return `${this.symbol} ${mappedArgs.join(" ")} ${this.idString}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,177 @@
|
|||
import { FileView, ItemView, MarkdownView, TextFileView, TFile, View } from "obsidian";
|
||||
import { Env } from "Env";
|
||||
import { App, FileView, getIcon, ItemView, MarkdownPostProcessorContext, MarkdownView, TextFileView, TFile, View } from "obsidian";
|
||||
import { childEl, firstChildEl, firstParentEl } from "utils/dom";
|
||||
import { Arr } from "utils/ts";
|
||||
|
||||
export type ObsViewMode = "reader" | "preview" | "source";
|
||||
export type ObsViewMode = "none" | "reader" | "preview" | "source";
|
||||
|
||||
export type TryGetFirstOptions = {
|
||||
dir: "down" | "up" | "downup" | "updown";
|
||||
element?: HTMLElement;
|
||||
postProcessorContext?: MarkdownPostProcessorContext;
|
||||
view?: View;
|
||||
};
|
||||
|
||||
export type TryGetAllOptions = Prettify<TryGetFirstOptions & {
|
||||
allDescendants: boolean;
|
||||
}>;
|
||||
|
||||
export class ObsAssistant {
|
||||
|
||||
/** Internal API */
|
||||
public static containerElFromPostProcessorContext(postProcessorContext: MarkdownPostProcessorContext) {
|
||||
Env.assert("containerEl" in postProcessorContext, "MarkdownPostProcessorContext does not have `containerEl`");
|
||||
// @ts-ignore
|
||||
const containerEl = postProcessorContext.containerEl;
|
||||
if (containerEl)
|
||||
return containerEl as HTMLElement;
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @returns The first container element found that is the root of a {@link View}. */
|
||||
public static viewContainerEl(options: TryGetFirstOptions) {
|
||||
return ObsAssistant.tryGetEl(Selector.WORKSPACE_LEAF_CONTENT, options);
|
||||
}
|
||||
|
||||
/** `<div class="view-content">` */
|
||||
public static viewContentEl(options: TryGetFirstOptions) {
|
||||
return ObsAssistant.tryGetEl(Selector.VIEW_CONTENT, options);
|
||||
}
|
||||
|
||||
/** The main reading view container. Sibling to {@link sourceViewEl}. */
|
||||
public static readingViewEl(options: TryGetFirstOptions) {
|
||||
return ObsAssistant.tryGetEl(Selector.READING_VIEW, options);
|
||||
}
|
||||
|
||||
public static sourceViewEl(options: TryGetFirstOptions) {
|
||||
return ObsAssistant.tryGetEl(Selector.SOURCE_VIEW, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the base class for all popover windows.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```html
|
||||
* <div class="popover hover-popover">`
|
||||
* <div class="markdown-embed is-loaded">
|
||||
* <div class="markdown-embed-content …">
|
||||
* <div class="markdown-preview-view markdown-rendered …">
|
||||
* </div>
|
||||
* …
|
||||
* </div>
|
||||
* </div>
|
||||
* </div>
|
||||
* ```
|
||||
*
|
||||
* @remarks `hover-popover` is a modifier class that extends or specifies the behavior. It indicates the popover appears on hover.
|
||||
*/
|
||||
public static popoverEl(options: TryGetFirstOptions) {
|
||||
return ObsAssistant.tryGetEl(Selector.POPOVER, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* The container that styles HTML rendered markdown (e.g, with `MarkdownRenderer`) the
|
||||
* standard way it looks in Obsidian's reader view.
|
||||
*
|
||||
* `<div class="markdown-preview-view">`
|
||||
*
|
||||
* Additional classes, such as `markdown-rendered` (content has been processed) or `is-readable-line-width` (when the user has enabled the "Readable line length" setting) might also appear.
|
||||
*
|
||||
* @remarks Do not assume that there's only one of these elements in the DOM. Plugins, like the Kanban plugin for example, might render different content in different small boxes. Each box then contains one of these preview view containers containing the rendered markdown.
|
||||
*/
|
||||
public static previewViewEl(options: TryGetAllOptions) {
|
||||
return ObsAssistant.tryGetEl(Selector.PREVIEW_VIEW, options);
|
||||
}
|
||||
|
||||
private static tryGetEl(selectors: string, options: TryGetFirstOptions | TryGetAllOptions): HTMLElement[] {
|
||||
let element: HTMLElement[] | null = null;
|
||||
|
||||
const findChildOrChildren = (options as TryGetAllOptions).allDescendants === true ? childEl : firstChildEl;
|
||||
|
||||
const find = (el: HTMLElement) => {
|
||||
if (options.dir === "down") {
|
||||
element = Arr.orNull(findChildOrChildren(el, selectors));
|
||||
}
|
||||
else if (options.dir === "up") {
|
||||
element = Arr.orNull(firstParentEl(el, selectors));
|
||||
}
|
||||
else if (options.dir === "downup") {
|
||||
element = Arr.orNull(findChildOrChildren(el, selectors));
|
||||
if (element === null)
|
||||
element = Arr.orNull(firstParentEl(el, selectors));
|
||||
}
|
||||
else if (options.dir === "updown") {
|
||||
element = Arr.orNull(firstParentEl(el, selectors));
|
||||
if (element === null)
|
||||
element = Arr.orNull(findChildOrChildren(el, selectors));
|
||||
}
|
||||
};
|
||||
|
||||
if (options.view !== undefined) {
|
||||
if (options.view instanceof ItemView)
|
||||
find(options.view.contentEl);
|
||||
else
|
||||
find(options.view.containerEl);
|
||||
}
|
||||
|
||||
if (element === null && options.postProcessorContext) {
|
||||
const postProcessorContainerEl = ObsAssistant.containerElFromPostProcessorContext(options.postProcessorContext);
|
||||
if (postProcessorContainerEl)
|
||||
find(postProcessorContainerEl);
|
||||
}
|
||||
|
||||
if (element === null && options.element) {
|
||||
find(options.element);
|
||||
}
|
||||
|
||||
return element ?? [];
|
||||
}
|
||||
|
||||
public static viewMode(view: View): ObsViewMode {
|
||||
return ObsAssistant.isInReadingView(view) ? "reader" : (ObsAssistant.isInSourceMode(view) ? "source" : "preview");
|
||||
let vm: ObsViewMode = "none";
|
||||
|
||||
const state = view.getState();
|
||||
const mode = state["mode"];
|
||||
|
||||
if (Env.str.is(mode)) {
|
||||
if (mode === "preview")
|
||||
vm = "reader"
|
||||
else if (mode === "source")
|
||||
vm = Env.bool.isTrue(state["source"]) ? "source" : "preview";
|
||||
}
|
||||
|
||||
return vm;
|
||||
}
|
||||
|
||||
public static viewModeFromContainerEl(viewContainerEl: HTMLElement): ObsViewMode {
|
||||
let vm: ObsViewMode = "none";
|
||||
|
||||
const dataMode = ObsAssistant.getAttributeFromContainerEl(viewContainerEl, Attributes.WorkspaceLeaf.DATA_MODE);
|
||||
if (dataMode) {
|
||||
if (dataMode === "preview")
|
||||
vm = "reader";
|
||||
else if (dataMode === "source") {
|
||||
const sourceViewEl = firstChildEl(viewContainerEl, Selector.SOURCE_VIEW);
|
||||
if (sourceViewEl !== null)
|
||||
vm = sourceViewEl.classList.contains("is-live-preview") ? "preview" : "source";
|
||||
}
|
||||
}
|
||||
|
||||
return vm;
|
||||
}
|
||||
|
||||
public static viewTypeFromContainerEl(viewContainerEl: HTMLElement) {
|
||||
const type = ObsAssistant.getAttributeFromContainerEl(viewContainerEl, Attributes.WorkspaceLeaf.DATA_TYPE);
|
||||
// file-explorer, markdown, …
|
||||
return type;
|
||||
}
|
||||
|
||||
private static getAttributeFromContainerEl(viewContainerEl: HTMLElement, attribute: string) {
|
||||
if (Env.isDev)
|
||||
Env.dev.assert(viewContainerEl.classList.contains(ClassName.WORKSPACE_LEAF_CONTENT), "Expected element to have class", ClassName.WORKSPACE_LEAF_CONTENT);
|
||||
return viewContainerEl.getAttribute(attribute);
|
||||
}
|
||||
|
||||
/** See also {@link View#getViewType()}. */
|
||||
|
|
@ -18,43 +184,89 @@ export class ObsAssistant {
|
|||
return "FileView";
|
||||
if (view instanceof ItemView)
|
||||
return "ItemView";
|
||||
|
||||
return "View";
|
||||
}
|
||||
|
||||
public static isInReadingView(view: View) {
|
||||
return view.getState()?.mode === "preview";
|
||||
}
|
||||
|
||||
public static isInLivePreview(view: View) {
|
||||
const state = view.getState();
|
||||
return state ? state.mode === "source" && state.source === false : false;
|
||||
}
|
||||
|
||||
public static isInSourceMode(view: View) {
|
||||
const state = view.getState();
|
||||
return state ? state.mode === "source" && state.source === true : false;
|
||||
if (view instanceof View)
|
||||
return "View";
|
||||
Env.dev.assert(false, "Expected View");
|
||||
return "NA";
|
||||
}
|
||||
|
||||
/** @returns If {@link view} extends {@link FileView}, returns the {@link TFile}. */
|
||||
public static getFileFromView(view?: View | null | undefined): TFile | null {
|
||||
if (view === null || view === undefined)
|
||||
return null;
|
||||
if (view instanceof MarkdownView)
|
||||
return view.file;
|
||||
if (view instanceof FileView) // (MarkdownView), TextFileView, EditableFileView, FileView
|
||||
// if (view instanceof MarkdownView)
|
||||
// return view.file;
|
||||
if (view instanceof FileView) // MarkdownView, TextFileView, EditableFileView, FileView
|
||||
return view.file;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static readerContainerEl(someAncestorEl: HTMLElement): HTMLDivElement | null {
|
||||
// <div class="workspace-leaf-content" data-type="markdown" data-mode="preview">
|
||||
// <div class="view-content">
|
||||
// <div class="markdown-reading-view" style="width: 100%; height: 100%;">
|
||||
/**
|
||||
* Gets the view of the specified type that is currently marked as the workspace’s
|
||||
* active view. This is usually the view inside the leaf that receives commands
|
||||
* and hotkeys.
|
||||
*
|
||||
* @returns The active {@link View} instance, or `null` if no matching view is the workspace’s active view.
|
||||
*/
|
||||
public static getActiveView(app: App): View | null {
|
||||
return app.workspace.getActiveViewOfType(View);
|
||||
}
|
||||
|
||||
return someAncestorEl.querySelector(Selector.READING_VIEW);
|
||||
public static getIcon(iconID: string, options?: { el?: HTMLElement, color?: string, fallbackColor?: string, fallbackIconID?: string }): SVGSVGElement | null {
|
||||
const {
|
||||
el = document.body,
|
||||
color,
|
||||
fallbackColor = "#919191",
|
||||
fallbackIconID,
|
||||
} = options || {};
|
||||
|
||||
let icon = getIcon(iconID);
|
||||
|
||||
Env.assert(icon !== null, "Icon ID not found:", iconID);
|
||||
if (icon === null && fallbackIconID !== undefined)
|
||||
icon = getIcon(fallbackIconID);
|
||||
if (icon === null)
|
||||
return null;
|
||||
|
||||
const iconColor = color ?? getComputedStyle(el).getPropertyValue(CssVar.Icon.COLOR).trim();
|
||||
Env.assert(iconColor, "CSS variable not found:", CssVar.Icon.COLOR);
|
||||
icon.setAttribute("stroke", iconColor || fallbackColor);
|
||||
icon.setAttribute("stroke-width", "1");
|
||||
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
|
||||
const Selector = {
|
||||
READING_VIEW: ".markdown-reading-view",
|
||||
const ClassName = {
|
||||
READING_VIEW: "markdown-reading-view",
|
||||
SOURCE_VIEW: "markdown-source-view",
|
||||
/** The root element of the {@link View}; the {@link View#containerEl}. */
|
||||
WORKSPACE_LEAF_CONTENT: "workspace-leaf-content",
|
||||
VIEW_CONTENT: "view-content",
|
||||
PREVIEW_VIEW: "markdown-preview-view",
|
||||
POPOVER: "popover",
|
||||
} as const;
|
||||
|
||||
const Selector = {
|
||||
READING_VIEW: "." + ClassName.READING_VIEW,
|
||||
/** Either live preview or source code. */
|
||||
SOURCE_VIEW: "." + ClassName.SOURCE_VIEW,
|
||||
PREVIEW_VIEW: "." + ClassName.PREVIEW_VIEW,
|
||||
POPOVER: "." + ClassName.POPOVER,
|
||||
WORKSPACE_LEAF_CONTENT: "." + ClassName.WORKSPACE_LEAF_CONTENT,
|
||||
VIEW_CONTENT: "." + ClassName.VIEW_CONTENT,
|
||||
} as const;
|
||||
|
||||
const CssVar = {
|
||||
/** https://docs.obsidian.md/Reference/CSS+variables/Foundations/Icons */
|
||||
Icon: {
|
||||
COLOR: "--icon-color"
|
||||
} as const,
|
||||
} as const;
|
||||
|
||||
const Attributes = {
|
||||
WorkspaceLeaf: {
|
||||
DATA_TYPE: "data-type",
|
||||
DATA_MODE: "data-mode",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
src/utils/dom.ts
Normal file
76
src/utils/dom.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
|
||||
|
||||
export function firstParentEl(childEl: HTMLElement, selectors: string): HTMLElement | null {
|
||||
return childEl.closest<HTMLElement>(selectors);
|
||||
}
|
||||
|
||||
export function firstChildEl(parentEl: HTMLElement, selectors: string): HTMLElement | null {
|
||||
return parentEl.querySelector<HTMLElement>(selectors);
|
||||
}
|
||||
|
||||
export function childEl(parentEl: HTMLElement, selectors: string): HTMLElement[] {
|
||||
return Array.from(parentEl.querySelectorAll<HTMLElement>(selectors));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a {@link element} is equal to or a descendant of {@link parent}.
|
||||
*
|
||||
* @remarks Traverses from the {@link element}'s position to see if the {@link parent} exists in its chain of ancestors.
|
||||
*
|
||||
* @param parent The potential parent node.
|
||||
* @param element The potential child node. If `null`, `false` is returned.
|
||||
* @returns True if child is a descendant of parent or is the same node.
|
||||
*/
|
||||
export function isDescendantOrEqual(parent: Element, element: Element | null): boolean {
|
||||
return parent.contains(element);
|
||||
}
|
||||
|
||||
let serialQueue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
export function queueAsync<T = void>(operation: () => Promise<T>): void {
|
||||
serialQueue = serialQueue
|
||||
.then(() => operation())
|
||||
.catch(err => {
|
||||
console.error('Queue operation failed:', err);
|
||||
});
|
||||
}
|
||||
|
||||
export function queueAsyncMicrotask<T = void>(operation: () => Promise<T>): void {
|
||||
queueMicrotask(() => {
|
||||
serialQueue = serialQueue
|
||||
.then(operation)
|
||||
.catch(err => {
|
||||
console.error('Microtask failed:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function sleep(milliseconds: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously waits for an element to be attached to the DOM.
|
||||
* @param element The element to check.
|
||||
* @param timeoutMs The maximum time to wait in milliseconds.
|
||||
* @returns A promise that resolves when the element has a parent, or rejects on timeout.
|
||||
*/
|
||||
export function waitForElementAttachment(element: HTMLElement, timeoutMs = 500, delay = 1, throwOnError = false): Promise<void | Error> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
const check = () => {
|
||||
if (element.parentElement) {
|
||||
resolve();
|
||||
} else if (Date.now() - startTime > timeoutMs) {
|
||||
const err = new Error(`Element was not attached to the DOM within ${timeoutMs}ms.`);
|
||||
if (throwOnError)
|
||||
reject(err);
|
||||
else
|
||||
resolve(err);
|
||||
} else {
|
||||
setTimeout(check, delay);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
12
src/utils/ts.ts
Normal file
12
src/utils/ts.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
|
||||
export const Arr = {
|
||||
firstOrNull: <T>(a: Array<T>): T | null => a.first() ?? null,
|
||||
orNull: <T>(v: T[] | T | null): T[] | null =>
|
||||
v === null
|
||||
? null
|
||||
: (Array.isArray(v) ? v : [v]),
|
||||
} as const;
|
||||
|
||||
export const Err = {
|
||||
toError: (e: unknown): Error => e instanceof Error ? e : new Error(String(e)),
|
||||
} as const;
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"lib": ["DOM", "ES2022"]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"1.1.0": "1.8.0",
|
||||
"1.0.7": "1.8.0",
|
||||
"1.0.6": "1.8.0",
|
||||
"1.0.5": "1.8.0",
|
||||
|
|
|
|||
Loading…
Reference in a new issue