feat: introduce a new markdown editor component and enhance UI panel state management, while banning the standard library logger.

This commit is contained in:
2026-01-19 04:39:33 +00:00
parent 853bf7fe01
commit f9b98d43dc
15 changed files with 1718 additions and 131 deletions

View File

@@ -0,0 +1,28 @@
---
name: ban-stdlib-logger
enabled: true
event: file
action: block
conditions:
- field: file_path
operator: regex_match
pattern: \.py$
- field: new_text
operator: regex_match
pattern: import logging|from logging import|logging\.getLogger
---
**Stdlib logger is banned in this project.**
Use the project's custom logger instead:
```python
from noteflow.infrastructure.logging import get_logger
logger = get_logger(__name__)
```
**Why:**
- Project uses structured logging with `get_logger()`
- Custom logger provides consistent formatting, context injection, and observability features
- Stdlib logger bypasses project logging configuration

View File

@@ -17,5 +17,8 @@
"enabledMcpjsonServers": [
"lightrag-mcp"
],
"outputStyle": "YAML Structured"
"outputStyle": "YAML Structured",
"enabledPlugins": {
"frontend-design@claude-code-plugins": true
}
}

854
client/package-lock.json generated
View File

@@ -43,6 +43,11 @@
"@tauri-apps/plugin-shell": "^2.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@tiptap/extension-placeholder": "^3.15.3",
"@tiptap/extension-task-item": "^3.15.3",
"@tiptap/extension-task-list": "^3.15.3",
"@tiptap/react": "^3.15.3",
"@tiptap/starter-kit": "^3.15.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -63,6 +68,7 @@
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"tiptap-markdown": "^0.9.0",
"vaul": "^0.9.9",
"vitest": "^4.0.16",
"zod": "^3.25.76"
@@ -3535,6 +3541,12 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@remirror/core-constants": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT"
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@@ -4518,6 +4530,479 @@
}
}
},
"node_modules/@tiptap/core": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.15.3.tgz",
"integrity": "sha512-bmXydIHfm2rEtGju39FiQNfzkFx9CDvJe+xem1dgEZ2P6Dj7nQX9LnA1ZscW7TuzbBRkL5p3dwuBIi3f62A66A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^3.15.3"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.15.3.tgz",
"integrity": "sha512-13x5UsQXtttFpoS/n1q173OeurNxppsdWgP3JfsshzyxIghhC141uL3H6SGYQLPU31AizgDs2OEzt6cSUevaZg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.15.3.tgz",
"integrity": "sha512-I8JYbkkUTNUXbHd/wCse2bR0QhQtJD7+0/lgrKOmGfv5ioLxcki079Nzuqqay3PjgYoJLIJQvm3RAGxT+4X91w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.15.3.tgz",
"integrity": "sha512-e88DG1bTy6hKxrt7iPVQhJnH5/EOrnKpIyp09dfRDgWrrW88fE0Qjys7a/eT8W+sXyXM3z10Ye7zpERWsrLZDg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3",
"@tiptap/pm": "^3.15.3"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.15.3.tgz",
"integrity": "sha512-MGwEkNT7ltst6XaWf0ObNgpKQ4PvuuV3igkBrdYnQS+qaAx9IF4isygVPqUc9DvjYC306jpyKsNqNrENIXcosA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.15.3"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.15.3.tgz",
"integrity": "sha512-x6LFt3Og6MFINYpsMzrJnz7vaT9Yk1t4oXkbJsJRSavdIWBEBcoRudKZ4sSe/AnsYlRJs8FY2uR76mt9e+7xAQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.15.3.tgz",
"integrity": "sha512-q1UB9icNfdJppTqMIUWfoRKkx5SSdWIpwZoL2NeOI5Ah3E20/dQKVttIgLhsE521chyvxCYCRaHD5tMNGKfhyw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3",
"@tiptap/pm": "^3.15.3"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.15.3.tgz",
"integrity": "sha512-AC72nI2gnogBuETCKbZekn+h6t5FGGcZG2abPGKbz/x9rwpb6qV2hcbAQ30t6M7H6cTOh2/Ut8bEV2MtMB15sw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.15.3.tgz",
"integrity": "sha512-jGI5XZpdo8GSYQFj7HY15/oEwC2m2TqZz0/Fln5qIhY32XlZhWrsMuMI6WbUJrTH16es7xO6jmRlDsc6g+vJWg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.15.3"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.15.3.tgz",
"integrity": "sha512-+3DVBleKKffadEJEdLYxmYAJOjHjLSqtiSFUE3RABT4V2ka1ODy2NIpyKX0o1SvQ5N1jViYT9Q+yUbNa6zCcDw==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "^3.15.3",
"@tiptap/pm": "^3.15.3"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.15.3.tgz",
"integrity": "sha512-Kaw0sNzP0bQI/xEAMSfIpja6xhsu9WqqAK/puzOIS1RKWO47Wps/tzqdSJ9gfslPIb5uY5mKCfy8UR8Xgiia8w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.15.3"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.15.3.tgz",
"integrity": "sha512-8HjxmeRbBiXW+7JKemAJtZtHlmXQ9iji398CPQ0yYde68WbIvUhHXjmbJE5pxFvvQTJ/zJv1aISeEOZN2bKBaw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.15.3.tgz",
"integrity": "sha512-G1GG6iN1YXPS+75arDpo+bYRzhr3dNDw99c7D7na3aDawa9Qp7sZ/bVrzFUUcVEce0cD6h83yY7AooBxEc67hA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.15.3.tgz",
"integrity": "sha512-FYkN7L6JsfwwNEntmLklCVKvgL0B0N47OXMacRk6kYKQmVQ4Nvc7q/VJLpD9sk4wh4KT1aiCBfhKEBTu5pv1fg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3",
"@tiptap/pm": "^3.15.3"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.15.3.tgz",
"integrity": "sha512-6XeuPjcWy7OBxpkgOV7bD6PATO5jhIxc8SEK4m8xn8nelGTBIbHGqK37evRv+QkC7E0MUryLtzwnmmiaxcKL0Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.15.3.tgz",
"integrity": "sha512-PdDXyBF9Wco9U1x6e+b7tKBWG+kqBDXDmaYXHkFm/gYuQCQafVJ5mdrDdKgkHDWVnJzMWZXBcZjT9r57qtlLWg==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3",
"@tiptap/pm": "^3.15.3"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.15.3.tgz",
"integrity": "sha512-n7y/MF9lAM5qlpuH5IR4/uq+kJPEJpe9NrEiH+NmkO/5KJ6cXzpJ6F4U17sMLf2SNCq+TWN9QK8QzoKxIn50VQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3",
"@tiptap/pm": "^3.15.3"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.15.3.tgz",
"integrity": "sha512-CCxL5ek1p0lO5e8aqhnPzIySldXRSigBFk2fP9OLgdl5qKFLs2MGc19jFlx5+/kjXnEsdQTFbGY1Sizzt0TVDw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.15.3"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.15.3.tgz",
"integrity": "sha512-UxqnTEEAKrL+wFQeSyC9z0mgyUUVRS2WTcVFoLZCE6/Xus9F53S4bl7VKFadjmqI4GpDk5Oe2IOUc72o129jWg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.15.3"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.15.3.tgz",
"integrity": "sha512-/8uhw528Iy0c9wF6tHCiIn0ToM0Ml6Ll2c/3iPRnKr4IjXwx2Lr994stUFihb+oqGZwV1J8CPcZJ4Ufpdqi4Dw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.15.3"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.15.3.tgz",
"integrity": "sha512-lc0Qu/1AgzcEfS67NJMj5tSHHhH6NtA6uUpvppEKGsvJwgE2wKG1onE4isrVXmcGRdxSMiCtyTDemPNMu6/ozQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.15.3.tgz",
"integrity": "sha512-XcHHnojT186hKIoOgcPBesXk89+caNGVUdMtc171Vcr/5s0dpnr4q5LfE+YRC+S85CpCxCRRnh84Ou+XRtOqrw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.15.3"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.15.3.tgz",
"integrity": "sha512-Y1P3eGNY7RxQs2BcR6NfLo9VfEOplXXHAqkOM88oowWWOE7dMNeFFZM9H8HNxoQgXJ7H0aWW9B7ZTWM9hWli2Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3"
}
},
"node_modules/@tiptap/extension-task-item": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-3.15.3.tgz",
"integrity": "sha512-bkrmouc1rE5n9ONw2G7+zCGfBRoF2HJWq8REThPMzg/6+L5GJJ5YTN4UmncaP48U9jHX8xeihjgg9Ypenjl4lw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.15.3"
}
},
"node_modules/@tiptap/extension-task-list": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-3.15.3.tgz",
"integrity": "sha512-nh8iBk1LHVIoqxphLoqZlLAN9fF2i9ZeK+2TjGSS35lfh7sYzRoSjNW0E81Uy48YuCzM1NQYghYR5Qfc7vm4jA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.15.3"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.15.3.tgz",
"integrity": "sha512-MhkBz8ZvrqOKtKNp+ZWISKkLUlTrDR7tbKZc2OnNcUTttL9dz0HwT+cg91GGz19fuo7ttDcfsPV6eVmflvGToA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.15.3.tgz",
"integrity": "sha512-r/IwcNN0W366jGu4Y0n2MiFq9jGa4aopOwtfWO4d+J0DyeS2m7Go3+KwoUqi0wQTiVU74yfi4DF6eRsMQ9/iHQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.15.3.tgz",
"integrity": "sha512-ycx/BgxR4rc9tf3ZyTdI98Z19yKLFfqM3UN+v42ChuIwkzyr9zyp7kG8dB9xN2lNqrD+5y/HyJobz/VJ7T90gA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3",
"@tiptap/pm": "^3.15.3"
}
},
"node_modules/@tiptap/pm": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.15.3.tgz",
"integrity": "sha512-Zm1BaU1TwFi3CQiisxjgnzzIus+q40bBKWLqXf6WEaus8Z6+vo1MT2pU52dBCMIRaW9XNDq3E5cmGtMc1AlveA==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-menu": "^1.2.4",
"prosemirror-model": "^1.24.1",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-trailing-node": "^3.0.0",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.38.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.15.3.tgz",
"integrity": "sha512-XvouB+Hrqw8yFmZLPEh+HWlMeRSjZfHSfWfWuw5d8LSwnxnPeu3Bg/rjHrRrdwb+7FumtzOnNWMorpb/PSOttQ==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.15.3",
"@tiptap/extension-floating-menu": "^3.15.3"
},
"peerDependencies": {
"@tiptap/core": "^3.15.3",
"@tiptap/pm": "^3.15.3",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.15.3.tgz",
"integrity": "sha512-ia+eQr9Mt1ln2UO+kK4kFTJOrZK4GhvZXFjpCCYuHtco3rhr2fZAIxEEY4cl/vo5VO5WWyPqxhkFeLcoWmNjSw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^3.15.3",
"@tiptap/extension-blockquote": "^3.15.3",
"@tiptap/extension-bold": "^3.15.3",
"@tiptap/extension-bullet-list": "^3.15.3",
"@tiptap/extension-code": "^3.15.3",
"@tiptap/extension-code-block": "^3.15.3",
"@tiptap/extension-document": "^3.15.3",
"@tiptap/extension-dropcursor": "^3.15.3",
"@tiptap/extension-gapcursor": "^3.15.3",
"@tiptap/extension-hard-break": "^3.15.3",
"@tiptap/extension-heading": "^3.15.3",
"@tiptap/extension-horizontal-rule": "^3.15.3",
"@tiptap/extension-italic": "^3.15.3",
"@tiptap/extension-link": "^3.15.3",
"@tiptap/extension-list": "^3.15.3",
"@tiptap/extension-list-item": "^3.15.3",
"@tiptap/extension-list-keymap": "^3.15.3",
"@tiptap/extension-ordered-list": "^3.15.3",
"@tiptap/extension-paragraph": "^3.15.3",
"@tiptap/extension-strike": "^3.15.3",
"@tiptap/extension-text": "^3.15.3",
"@tiptap/extension-underline": "^3.15.3",
"@tiptap/extensions": "^3.15.3",
"@tiptap/pm": "^3.15.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tootallnate/quickjs-emscripten": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
@@ -4651,6 +5136,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT"
},
"node_modules/@types/mocha": {
"version": "10.0.10",
"resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz",
@@ -4679,14 +5186,12 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -4697,7 +5202,6 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
@@ -4717,6 +5221,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@types/which/-/which-2.0.2.tgz",
@@ -5741,7 +6251,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
@@ -6165,9 +6674,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001764",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
"integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==",
"version": "1.0.30001765",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz",
"integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==",
"dev": true,
"funding": [
{
@@ -6584,6 +7093,12 @@
"node": ">=12.0.0"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -7351,7 +7866,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -9362,6 +9876,21 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT"
},
"node_modules/locate-app": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/locate-app/-/locate-app-2.5.0.tgz",
@@ -9636,12 +10165,53 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it-task-lists": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz",
"integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==",
"license": "ISC"
},
"node_modules/markdown-it/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/mdn-data": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
"integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
"license": "CC0-1.0"
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -10204,6 +10774,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -10795,6 +11371,201 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/prosemirror-changeset": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz",
"integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-collab": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz",
"integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-markdown": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz",
"integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==",
"license": "MIT",
"dependencies": {
"@types/markdown-it": "^14.0.0",
"markdown-it": "^14.0.0",
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-menu": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz",
"integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==",
"license": "MIT",
"dependencies": {
"crelt": "^1.0.0",
"prosemirror-commands": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.4",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-basic": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-trailing-node": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
"license": "MIT",
"dependencies": {
"@remirror/core-constants": "3.0.0",
"escape-string-regexp": "^4.0.0"
},
"peerDependencies": {
"prosemirror-model": "^1.22.1",
"prosemirror-state": "^1.4.2",
"prosemirror-view": "^1.33.8"
}
},
"node_modules/prosemirror-transform": {
"version": "1.10.5",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz",
"integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.5",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.5.tgz",
"integrity": "sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/proxy-agent": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",
@@ -10852,6 +11623,15 @@
"node": ">=6"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/query-selector-shadow-dom": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz",
@@ -11537,6 +12317,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/run-async": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/run-async/-/run-async-4.0.6.tgz",
@@ -12437,6 +13223,46 @@
"node": ">=14.0.0"
}
},
"node_modules/tiptap-markdown": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
"integrity": "sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==",
"license": "MIT",
"workspaces": [
"example"
],
"dependencies": {
"@types/markdown-it": "^13.0.7",
"markdown-it": "^14.1.0",
"markdown-it-task-lists": "^2.1.1",
"prosemirror-markdown": "^1.11.1"
},
"peerDependencies": {
"@tiptap/core": "^3.0.1"
}
},
"node_modules/tiptap-markdown/node_modules/@types/linkify-it": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz",
"integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==",
"license": "MIT"
},
"node_modules/tiptap-markdown/node_modules/@types/markdown-it": {
"version": "13.0.9",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz",
"integrity": "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^3",
"@types/mdurl": "^1"
}
},
"node_modules/tiptap-markdown/node_modules/@types/mdurl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz",
"integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==",
"license": "MIT"
},
"node_modules/tldts": {
"version": "7.0.19",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz",
@@ -13072,6 +13898,12 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
@@ -13906,6 +14738,12 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",

View File

@@ -68,6 +68,11 @@
"@tauri-apps/plugin-shell": "^2.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@tiptap/extension-placeholder": "^3.15.3",
"@tiptap/extension-task-item": "^3.15.3",
"@tiptap/extension-task-list": "^3.15.3",
"@tiptap/react": "^3.15.3",
"@tiptap/starter-kit": "^3.15.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -88,6 +93,7 @@
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"tiptap-markdown": "^0.9.0",
"vaul": "^0.9.9",
"vitest": "^4.0.16",
"zod": "^3.25.76"
@@ -108,8 +114,8 @@
"@wdio/mocha-framework": "^9.22.0",
"@wdio/spec-reporter": "^9.20.0",
"@wdio/types": "^9.20.0",
"edgedriver": "^6.1.0",
"autoprefixer": "^10.4.21",
"edgedriver": "^6.1.0",
"eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",

View File

@@ -8,6 +8,7 @@ import type { ImperativePanelHandle } from 'react-resizable-panels';
import { type NoteEdit, TimestampedNotesEditor } from '@/components/timestamped-notes-editor';
import { Button } from '@/components/ui/button';
import { buttonSize } from '@/lib/styles';
export interface NotesPanelProps {
panelRef: RefObject<ImperativePanelHandle | null>;
@@ -16,6 +17,7 @@ export interface NotesPanelProps {
isRecording: boolean;
notes: NoteEdit[];
onNotesChange: (notes: NoteEdit[]) => void;
onTogglePanel?: () => void;
}
export function NotesPanel({
@@ -25,7 +27,23 @@ export function NotesPanel({
isRecording,
notes,
onNotesChange,
onTogglePanel,
}: NotesPanelProps) {
const handleCollapse = () => {
if (onTogglePanel) {
onTogglePanel();
} else {
panelRef.current?.collapse();
}
};
const handleExpand = () => {
if (onTogglePanel) {
onTogglePanel();
} else {
panelRef.current?.expand();
}
};
return (
<div className="h-full flex flex-col border-l border-border bg-card/50">
{showPanel ? (
@@ -35,8 +53,8 @@ export function NotesPanel({
<Button
variant="ghost"
size="sm"
onClick={() => panelRef.current?.collapse()}
className="h-7 w-7 p-0"
onClick={handleCollapse}
className={buttonSize.iconSm}
title="Collapse notes panel"
>
<PanelRightClose className="h-4 w-4" />
@@ -52,19 +70,23 @@ export function NotesPanel({
</div>
</div>
) : (
<div className="h-full flex flex-col items-center pt-4">
<Button
variant="ghost"
size="sm"
onClick={() => panelRef.current?.expand()}
className="h-8 w-8 p-0"
title="Expand notes panel"
>
<PanelRightOpen className="h-4 w-4" />
</Button>
<span className="text-[10px] text-muted-foreground mt-2 [writing-mode:vertical-rl] rotate-180">
Notes
</span>
<div className="h-full flex flex-col border-l border-border bg-card/50">
<div className="p-2 flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={handleExpand}
className={buttonSize.iconSm}
title="Expand notes panel"
>
<PanelRightOpen className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 flex items-center justify-center">
<span className="text-xs font-medium text-muted-foreground [writing-mode:vertical-lr]">
Notes
</span>
</div>
</div>
)}
</div>

View File

@@ -2,13 +2,14 @@
* Stats panel component for the recording page.
*/
import { BarChart3, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import type { RefObject } from 'react';
import type { ImperativePanelHandle } from 'react-resizable-panels';
import type { FinalSegment } from '@/api/types';
import { StatsContent } from '@/components/recording/stats-content';
import { Button } from '@/components/ui/button';
import { buttonSize } from '@/lib/styles';
export interface StatsPanelProps {
panelRef: RefObject<ImperativePanelHandle | null>;
@@ -20,6 +21,7 @@ export interface StatsPanelProps {
isVadActive: boolean;
audioLevel: number | null;
speakerNameMap: Map<string, string>;
onTogglePanel?: () => void;
}
export function StatsPanel({
@@ -32,7 +34,24 @@ export function StatsPanel({
isVadActive,
audioLevel,
speakerNameMap,
onTogglePanel,
}: StatsPanelProps) {
const handleCollapse = () => {
if (onTogglePanel) {
onTogglePanel();
} else {
panelRef.current?.collapse();
}
};
const handleExpand = () => {
if (onTogglePanel) {
onTogglePanel();
} else {
panelRef.current?.expand();
}
};
return (
<div className="h-full flex flex-col border-l border-border bg-card/50 overflow-auto">
{showPanel ? (
@@ -42,8 +61,8 @@ export function StatsPanel({
<Button
variant="ghost"
size="sm"
onClick={() => panelRef.current?.collapse()}
className="h-7 w-7 p-0"
onClick={handleCollapse}
className={buttonSize.iconSm}
title="Collapse stats panel"
>
<PanelLeftClose className="h-4 w-4" />
@@ -60,20 +79,23 @@ export function StatsPanel({
/>
</div>
) : (
<div className="h-full flex flex-col items-center pt-4">
<Button
variant="ghost"
size="sm"
onClick={() => panelRef.current?.expand()}
className="h-8 w-8 p-0"
title="Expand stats panel"
>
<PanelLeftOpen className="h-4 w-4" />
</Button>
<span className="text-[10px] text-muted-foreground mt-2 [writing-mode:vertical-rl] rotate-180">
<BarChart3 className="h-3 w-3 mb-1" />
Stats
</span>
<div className="h-full flex flex-col border-l border-border bg-card/50">
<div className="p-2 flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={handleExpand}
className={buttonSize.iconSm}
title="Expand stats panel"
>
<PanelLeftOpen className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 flex items-center justify-center">
<span className="text-xs font-medium text-muted-foreground [writing-mode:vertical-lr]">
Stats
</span>
</div>
</div>
)}
</div>

View File

@@ -1,12 +1,15 @@
import { AnimatePresence, motion } from 'framer-motion';
import { History, PenLine, Save, Trash2 } from 'lucide-react';
import { Code, History, PenLine, Save, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Editor } from '@tiptap/react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { MarkdownEditor, getEditorMarkdown, useMarkdownEditor } from '@/components/ui/markdown-editor';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { formatElapsedTime } from '@/lib/format';
import { generateUuid } from '@/lib/id-utils';
import { cn } from '@/lib/utils';
@@ -39,9 +42,47 @@ export function TimestampedNotesEditor({
const [currentNote, setCurrentNote] = useState('');
const [showHistory, setShowHistory] = useState(false);
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
const [isRawMode, setIsRawMode] = useState(false);
const lastSavedContent = useRef('');
const lastAutoSaveTime = useRef(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const editorRef = useRef<Editor | null>(null);
// WYSIWYG editor instance
const editor = useMarkdownEditor({
content: currentNote,
placeholder: isRecording
? "Take notes during your meeting...\n\nTip: Use the toolbar for formatting."
: 'Start recording to take timestamped notes...',
editable: isRecording,
});
// Keep editor ref updated
useEffect(() => {
editorRef.current = editor;
}, [editor]);
// Sync editor content when currentNote changes externally (e.g., from getCombinedContent)
useEffect(() => {
if (editor && !isRawMode) {
const editorContent = getEditorMarkdown(editor);
if (currentNote !== editorContent) {
editor.commands.setContent(currentNote);
}
}
}, [editor, currentNote, isRawMode]);
// Handle mode toggle - sync content between modes
const handleModeToggle = useCallback((rawMode: boolean) => {
if (rawMode && editor) {
// Switching to raw: get markdown from editor
setCurrentNote(getEditorMarkdown(editor));
} else if (!rawMode && editor) {
// Switching to WYSIWYG: set editor content from currentNote
editor.commands.setContent(currentNote);
}
setIsRawMode(rawMode);
}, [editor, currentNote]);
// Get combined note content from all edits
const getCombinedContent = useCallback(() => {
@@ -65,7 +106,9 @@ export function TimestampedNotesEditor({
// Save note with timestamp
const saveNote = useCallback(
(isAutoSave = false) => {
const trimmed = currentNote.trim();
// Get content from appropriate source based on mode
const content = isRawMode ? currentNote : getEditorMarkdown(editorRef.current);
const trimmed = content.trim();
if (!trimmed || trimmed === lastSavedContent.current) {
return false;
}
@@ -85,7 +128,7 @@ export function TimestampedNotesEditor({
lastAutoSaveTime.current = elapsedTime;
return true;
},
[currentNote, elapsedTime, notes, onNotesChange]
[currentNote, elapsedTime, notes, onNotesChange, isRawMode]
);
// Auto-save at periodic intervals
@@ -116,32 +159,44 @@ export function TimestampedNotesEditor({
saveNote(false);
};
// Generic text insertion
// Generic text insertion - handles both raw and WYSIWYG modes
const insertText = useCallback((text: string, cursorOffset: number = 0) => {
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newContent = currentNote.substring(0, start) + text + currentNote.substring(end);
setCurrentNote(newContent);
// Focus and move cursor
setTimeout(() => {
textarea.focus();
const newCursor = start + text.length + cursorOffset;
textarea.setSelectionRange(newCursor, newCursor);
}, 0);
} else {
setCurrentNote((prev) => prev + text);
if (isRawMode) {
// Raw mode: insert into textarea
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newContent = currentNote.substring(0, start) + text + currentNote.substring(end);
setCurrentNote(newContent);
// Focus and move cursor
setTimeout(() => {
textarea.focus();
const newCursor = start + text.length + cursorOffset;
textarea.setSelectionRange(newCursor, newCursor);
}, 0);
} else {
setCurrentNote((prev) => prev + text);
}
} else if (editor) {
// WYSIWYG mode: insert into editor
editor.chain().focus().insertContent(text).run();
}
}, [currentNote]);
}, [currentNote, isRawMode, editor]);
const handleInsertTimestamp = useCallback(() => {
insertText(`\n\n---\n**[${formatElapsedTime(elapsedTime)}]** `);
}, [insertText, elapsedTime]);
const handleAddActionItem = useCallback(() => {
insertText('\n- [ ] ');
}, [insertText]);
if (!isRawMode && editor) {
// WYSIWYG mode: toggle task list
editor.chain().focus().toggleTaskList().run();
} else {
// Raw mode: insert markdown checkbox
insertText('\n- [ ] ');
}
}, [insertText, isRawMode, editor]);
const handleAddDecision = useCallback(() => {
insertText('\n> **DECISION:** ');
@@ -294,50 +349,81 @@ export function TimestampedNotesEditor({
{/* Quick Actions Toolbar */}
{isRecording && (
<div className="px-3 pt-2">
<div className="flex items-center justify-between px-3 pt-2">
<NotesQuickActions
elapsedTime={elapsedTime}
onInsertTimestamp={handleInsertTimestamp}
onAddActionItem={handleAddActionItem}
onAddDecision={handleAddDecision}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleModeToggle(!isRawMode)}
className="h-6 px-2 text-xs gap-1"
>
<Code className="h-3 w-3" />
{isRawMode ? 'Preview' : 'Raw'}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{isRawMode ? 'Switch to WYSIWYG editor' : 'Switch to raw markdown'}
</TooltipContent>
</Tooltip>
</div>
)}
{/* Editor */}
<div className="flex-1 px-3 pt-1 flex flex-col min-h-0">
<div className="flex-1 relative">
<Textarea
ref={textareaRef}
value={currentNote}
onChange={(e) => setCurrentNote(e.target.value)}
onBlur={handleBlur}
placeholder={
isRecording
? "Take notes during your meeting...\n\nTip: Use **bold** and *italic* for markdown formatting.\nClick 'Insert Time' to add a timestamp marker."
: 'Start recording to take timestamped notes...'
}
disabled={!isRecording}
className={cn(
'h-full resize-none font-mono text-sm',
'bg-background border-border',
!isRecording && 'opacity-50 cursor-not-allowed'
)}
/>
{isRawMode ? (
<Textarea
ref={textareaRef}
value={currentNote}
onChange={(e) => setCurrentNote(e.target.value)}
onBlur={handleBlur}
placeholder={
isRecording
? "Take notes during your meeting...\n\nTip: Use **bold** and *italic* for markdown formatting.\nClick 'Insert Time' to add a timestamp marker."
: 'Start recording to take timestamped notes...'
}
disabled={!isRecording}
className={cn(
'h-full resize-none font-mono text-sm',
'bg-background border-border',
!isRecording && 'opacity-50 cursor-not-allowed'
)}
/>
) : (
<MarkdownEditor
editor={editor}
disabled={!isRecording}
showToolbar={isRecording}
className="h-full"
/>
)}
</div>
{/* Status bar */}
{isRecording && (
<div className="flex items-center justify-between pt-2 text-[10px] text-muted-foreground">
<span>
{currentNote.length} chars {currentNote.split(/\s+/).filter(Boolean).length} words
{(() => {
const content = isRawMode ? currentNote : getEditorMarkdown(editor);
return `${content.length} chars • ${content.split(/\s+/).filter(Boolean).length} words`;
})()}
</span>
<span>
{lastSavedContent.current !== currentNote.trim() ? (
<span className="text-warning">Unsaved changes</span>
) : (
<span className="text-success">Saved</span>
)}
{(() => {
const content = isRawMode ? currentNote : getEditorMarkdown(editor);
return lastSavedContent.current !== content.trim() ? (
<span className="text-warning">Unsaved changes</span>
) : (
<span className="text-success">Saved</span>
);
})()}
</span>
</div>
)}

View File

@@ -0,0 +1,365 @@
/**
* WYSIWYG Markdown Editor component built on TipTap.
*
* Provides rich text editing with markdown import/export.
* Uses existing Toggle, Tooltip, and Separator UI components for consistency.
*/
import { Bold, Code, Italic, List, ListOrdered, Quote, Redo, Undo } from 'lucide-react';
import { useEffect } from 'react';
import { EditorContent, useEditor, type Editor } from '@tiptap/react';
import Placeholder from '@tiptap/extension-placeholder';
import StarterKit from '@tiptap/starter-kit';
import TaskItem from '@tiptap/extension-task-item';
import TaskList from '@tiptap/extension-task-list';
import { Markdown } from 'tiptap-markdown';
import { Separator } from '@/components/ui/separator';
import { Toggle } from '@/components/ui/toggle';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
// ---------------------------------------------------------------------------
// Type-safe markdown storage access
// ---------------------------------------------------------------------------
interface MarkdownStorage {
getMarkdown: () => string;
}
/**
* Type-safe accessor for tiptap-markdown storage.
* The Markdown extension adds this storage at runtime.
*
* TipTap's Storage type is `{}` but extensions add properties dynamically.
* We use bracket notation and runtime validation for safe access.
*/
function getMarkdownStorage(editor: Editor): MarkdownStorage {
const storage = editor.storage;
// Access via bracket notation to avoid TS structural typing issues
const markdown = (storage as Record<string, unknown>)['markdown'];
if (markdown && typeof markdown === 'object' && 'getMarkdown' in markdown) {
return markdown as MarkdownStorage;
}
// Fallback (should never happen when Markdown extension is installed)
return { getMarkdown: () => '' };
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface MarkdownEditorProps {
/** Initial content as markdown string */
content?: string;
/** Placeholder text when editor is empty */
placeholder?: string;
/** Whether the editor is disabled */
disabled?: boolean;
/** Called when content changes (debounced) */
onChange?: (markdown: string) => void;
/** Additional class names for the editor container */
className?: string;
/** Whether to show the formatting toolbar */
showToolbar?: boolean;
/** External editor instance (if using useMarkdownEditor hook) */
editor?: Editor | null;
}
export interface UseMarkdownEditorOptions {
/** Initial content as markdown string */
content?: string;
/** Placeholder text */
placeholder?: string;
/** Called when content changes */
onChange?: (markdown: string) => void;
/** Whether editor is editable */
editable?: boolean;
}
// ---------------------------------------------------------------------------
// Hook: useMarkdownEditor
// ---------------------------------------------------------------------------
/**
* Hook to create and manage a TipTap editor instance with markdown support.
* Use this when you need external access to the editor (e.g., for custom toolbars).
*/
export function useMarkdownEditor({
content = '',
placeholder = '',
onChange,
editable = true,
}: UseMarkdownEditorOptions = {}): Editor | null {
const editor = useEditor({
extensions: [
StarterKit.configure({
// Disable default task list since we're using the dedicated extension
bulletList: { keepMarks: true },
orderedList: { keepMarks: true },
}),
TaskList,
TaskItem.configure({
nested: true,
}),
Placeholder.configure({
placeholder,
emptyEditorClass: 'is-editor-empty',
}),
Markdown.configure({
html: false, // Don't allow raw HTML
transformPastedText: true, // Parse markdown on paste
transformCopiedText: true, // Copy as markdown
}),
],
content,
editable,
editorProps: {
attributes: {
class: cn(
'prose prose-sm dark:prose-invert max-w-none',
'focus:outline-none min-h-[100px] px-3 py-2',
'[&_ul[data-type="taskList"]]:list-none [&_ul[data-type="taskList"]]:pl-0',
'[&_ul[data-type="taskList"]_li]:flex [&_ul[data-type="taskList"]_li]:gap-2 [&_ul[data-type="taskList"]_li]:items-start',
'[&_ul[data-type="taskList"]_li_label]:mt-0.5',
'[&_ul[data-type="taskList"]_li_div]:flex-1'
),
},
},
onUpdate: ({ editor: e }) => {
if (onChange) {
const markdown = getMarkdownStorage(e).getMarkdown();
onChange(markdown);
}
},
});
// Update content when prop changes
useEffect(() => {
if (editor && content !== getMarkdownStorage(editor).getMarkdown()) {
editor.commands.setContent(content);
}
}, [editor, content]);
// Update editable state
useEffect(() => {
if (editor) {
editor.setEditable(editable);
}
}, [editor, editable]);
return editor;
}
// ---------------------------------------------------------------------------
// Toolbar Button Component
// ---------------------------------------------------------------------------
interface ToolbarButtonProps {
icon: React.ComponentType<{ className?: string }>;
label: string;
shortcut?: string;
isActive?: boolean;
disabled?: boolean;
onClick: () => void;
}
function ToolbarButton({ icon: Icon, label, shortcut, isActive, disabled, onClick }: ToolbarButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Toggle
size="sm"
pressed={isActive}
onPressedChange={onClick}
disabled={disabled}
className="h-7 w-7 p-0"
aria-label={label}
>
<Icon className="h-3.5 w-3.5" />
</Toggle>
</TooltipTrigger>
<TooltipContent side="bottom" className="flex items-center gap-1.5">
<span>{label}</span>
{shortcut && <kbd className="text-[10px] text-muted-foreground">{shortcut}</kbd>}
</TooltipContent>
</Tooltip>
);
}
// ---------------------------------------------------------------------------
// Editor Toolbar Component
// ---------------------------------------------------------------------------
interface EditorToolbarProps {
editor: Editor | null;
}
function EditorToolbar({ editor }: EditorToolbarProps) {
if (!editor) {
return null;
}
return (
<div className="flex items-center gap-0.5 p-1 border-b border-border bg-muted/30 rounded-t-md">
{/* Text formatting */}
<ToolbarButton
icon={Bold}
label="Bold"
shortcut="⌘B"
isActive={editor.isActive('bold')}
disabled={!editor.can().toggleBold()}
onClick={() => editor.chain().focus().toggleBold().run()}
/>
<ToolbarButton
icon={Italic}
label="Italic"
shortcut="⌘I"
isActive={editor.isActive('italic')}
disabled={!editor.can().toggleItalic()}
onClick={() => editor.chain().focus().toggleItalic().run()}
/>
<ToolbarButton
icon={Code}
label="Code"
shortcut="⌘E"
isActive={editor.isActive('code')}
disabled={!editor.can().toggleCode()}
onClick={() => editor.chain().focus().toggleCode().run()}
/>
<Separator orientation="vertical" className="h-5 mx-1" />
{/* Lists */}
<ToolbarButton
icon={List}
label="Bullet List"
isActive={editor.isActive('bulletList')}
disabled={!editor.can().toggleBulletList()}
onClick={() => editor.chain().focus().toggleBulletList().run()}
/>
<ToolbarButton
icon={ListOrdered}
label="Numbered List"
isActive={editor.isActive('orderedList')}
disabled={!editor.can().toggleOrderedList()}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
/>
<ToolbarButton
icon={Quote}
label="Blockquote"
isActive={editor.isActive('blockquote')}
disabled={!editor.can().toggleBlockquote()}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
/>
<Separator orientation="vertical" className="h-5 mx-1" />
{/* Undo/Redo */}
<ToolbarButton
icon={Undo}
label="Undo"
shortcut="⌘Z"
disabled={!editor.can().undo()}
onClick={() => editor.chain().focus().undo().run()}
/>
<ToolbarButton
icon={Redo}
label="Redo"
shortcut="⌘⇧Z"
disabled={!editor.can().redo()}
onClick={() => editor.chain().focus().redo().run()}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Main MarkdownEditor Component
// ---------------------------------------------------------------------------
/**
* WYSIWYG Markdown Editor with optional toolbar.
*
* Can be used standalone or with an external editor instance from useMarkdownEditor.
*
* @example
* // Standalone usage
* <MarkdownEditor
* content="**Hello** world"
* onChange={(md) => console.log(md)}
* placeholder="Start typing..."
* />
*
* @example
* // With external editor control
* const editor = useMarkdownEditor({ content: initialMarkdown });
* <MarkdownEditor editor={editor} showToolbar />
* // Access markdown: getEditorMarkdown(editor)
*/
export function MarkdownEditor({
content = '',
placeholder = '',
disabled = false,
onChange,
className,
showToolbar = true,
editor: externalEditor,
}: MarkdownEditorProps) {
// Use external editor if provided, otherwise create internal one
const internalEditor = useMarkdownEditor({
content,
placeholder,
onChange,
editable: !disabled,
});
const editor = externalEditor ?? internalEditor;
// Cleanup internal editor on unmount (external editor is managed externally)
useEffect(() => {
return () => {
if (!externalEditor && internalEditor) {
internalEditor.destroy();
}
};
}, [externalEditor, internalEditor]);
return (
<div
className={cn(
'rounded-md border border-input bg-background',
disabled && 'opacity-50 cursor-not-allowed',
className
)}
>
{showToolbar && <EditorToolbar editor={editor} />}
<EditorContent
editor={editor}
className={cn(
'min-h-[100px] overflow-auto',
'[&_.is-editor-empty:first-child::before]:content-[attr(data-placeholder)]',
'[&_.is-editor-empty:first-child::before]:text-muted-foreground',
'[&_.is-editor-empty:first-child::before]:float-left',
'[&_.is-editor-empty:first-child::before]:h-0',
'[&_.is-editor-empty:first-child::before]:pointer-events-none'
)}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Utility: Get markdown from editor
// ---------------------------------------------------------------------------
/**
* Helper to safely get markdown content from an editor instance.
*/
export function getEditorMarkdown(editor: Editor | null): string {
if (!editor) {
return '';
}
return getMarkdownStorage(editor).getMarkdown();
}

View File

@@ -7,11 +7,11 @@ const COLLAPSED_SIZE_PERCENT = 3;
/** Minimum transcript panel size to ensure readability */
const MIN_TRANSCRIPT_SIZE_PERCENT = 30;
/** Default notes panel size when expanded */
const DEFAULT_NOTES_SIZE_PERCENT = 20;
const DEFAULT_NOTES_SIZE_PERCENT = 25;
/** Default stats panel size when expanded */
const DEFAULT_STATS_SIZE_PERCENT = 20;
const DEFAULT_STATS_SIZE_PERCENT = 22;
/** Default transcript panel size (100 - notes - stats) */
const DEFAULT_TRANSCRIPT_SIZE_PERCENT = 60;
const DEFAULT_TRANSCRIPT_SIZE_PERCENT = 53;
/** Total panel group size must equal this percentage */
const TOTAL_SIZE_PERCENT = 100;
/** Number of side panels (notes + stats) */
@@ -22,7 +22,9 @@ const MAX_NOTES_SIZE_PERCENT = 40;
const MAX_STATS_SIZE_PERCENT = 35;
/** Minimum size for notes panel when expanded */
const MIN_NOTES_SIZE_PERCENT = 25;
const MIN_NOTES_SIZE_PERCENT = 20;
/** Minimum size for stats panel when expanded */
const MIN_STATS_SIZE_PERCENT = 18;
interface PanelPreferences {
showNotesPanel: boolean;
@@ -40,16 +42,28 @@ const DEFAULT_PREFERENCES: PanelPreferences = {
transcriptPanelSize: DEFAULT_TRANSCRIPT_SIZE_PERCENT,
};
/**
* Clamp a value between min and max bounds.
*/
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/**
* Normalize panel sizes to ensure they sum to 100% based on visibility.
* Accounts for collapsed panels taking COLLAPSED_SIZE_PERCENT when hidden.
* Clamps panel sizes to their min/max constraints to handle legacy stored values.
*/
function normalizeSizes(prefs: PanelPreferences): PanelPreferences {
const { showNotesPanel, showStatsPanel } = prefs;
// Calculate effective sizes for side panels
const notesSize = showNotesPanel ? prefs.notesPanelSize : COLLAPSED_SIZE_PERCENT;
const statsSize = showStatsPanel ? prefs.statsPanelSize : COLLAPSED_SIZE_PERCENT;
// Clamp stored sizes to valid ranges (handles legacy values that are now invalid)
const clampedNotesSize = clamp(prefs.notesPanelSize, MIN_NOTES_SIZE_PERCENT, MAX_NOTES_SIZE_PERCENT);
const clampedStatsSize = clamp(prefs.statsPanelSize, MIN_STATS_SIZE_PERCENT, MAX_STATS_SIZE_PERCENT);
// Calculate effective sizes for side panels (use collapsed size when hidden)
const notesSize = showNotesPanel ? clampedNotesSize : COLLAPSED_SIZE_PERCENT;
const statsSize = showStatsPanel ? clampedStatsSize : COLLAPSED_SIZE_PERCENT;
// Calculate transcript size to fill remaining space
let transcriptSize = TOTAL_SIZE_PERCENT - notesSize - statsSize;
@@ -65,8 +79,8 @@ function normalizeSizes(prefs: PanelPreferences): PanelPreferences {
const sideSpace = (remaining - SIDE_PANEL_COUNT * COLLAPSED_SIZE_PERCENT) / SIDE_PANEL_COUNT;
return {
...prefs,
notesPanelSize: sideSpace,
statsPanelSize: sideSpace,
notesPanelSize: Math.max(sideSpace, MIN_NOTES_SIZE_PERCENT),
statsPanelSize: Math.max(sideSpace, MIN_STATS_SIZE_PERCENT),
transcriptPanelSize: transcriptSize,
};
}
@@ -74,6 +88,8 @@ function normalizeSizes(prefs: PanelPreferences): PanelPreferences {
return {
...prefs,
notesPanelSize: clampedNotesSize,
statsPanelSize: clampedStatsSize,
transcriptPanelSize: transcriptSize,
};
}
@@ -107,6 +123,7 @@ export {
MAX_NOTES_SIZE_PERCENT,
MAX_STATS_SIZE_PERCENT,
MIN_NOTES_SIZE_PERCENT,
MIN_STATS_SIZE_PERCENT,
};
export function usePanelPreferences() {

View File

@@ -0,0 +1,175 @@
/**
* Custom hook for managing recording page panel state and behavior.
* Handles cascade prevention, programmatic expand/collapse, and size synchronization.
*/
import { useCallback, useEffect, useRef } from 'react';
import type { ImperativePanelHandle } from 'react-resizable-panels';
import {
COLLAPSED_SIZE_PERCENT,
MAX_NOTES_SIZE_PERCENT,
MAX_STATS_SIZE_PERCENT,
MIN_NOTES_SIZE_PERCENT,
MIN_STATS_SIZE_PERCENT,
} from '@/hooks/use-panel-preferences';
export interface UseRecordingPanelsOptions {
showNotesPanel: boolean;
showStatsPanel: boolean;
setShowNotesPanel: (value: boolean) => void;
setShowStatsPanel: (value: boolean) => void;
}
export interface UseRecordingPanelsResult {
notesPanelRef: React.RefObject<ImperativePanelHandle>;
statsPanelRef: React.RefObject<ImperativePanelHandle>;
handleToggleNotesPanel: () => void;
handleToggleStatsPanel: () => void;
handleNotesCollapse: () => void;
handleStatsCollapse: () => void;
handleNotesExpand: () => void;
handleStatsExpand: () => void;
}
/**
* Manages recording page panel behavior including:
* - Cascade prevention: toggling one panel doesn't affect the other
* - Programmatic expand/collapse with proper visual sizing
* - Syncing panel state with preferences
*/
export function useRecordingPanels({
showNotesPanel,
showStatsPanel,
setShowNotesPanel,
setShowStatsPanel,
}: UseRecordingPanelsOptions): UseRecordingPanelsResult {
// Panel refs for imperative collapse/expand
// Cast needed because ResizablePanel ref prop expects non-null RefObject
const notesPanelRef = useRef<ImperativePanelHandle>(null) as React.RefObject<ImperativePanelHandle>;
const statsPanelRef = useRef<ImperativePanelHandle>(null) as React.RefObject<ImperativePanelHandle>;
// Track when ANY programmatic panel operation is in progress.
// When toggling one panel, the library may auto-collapse the other panel to fit,
// which would trigger its onCollapse callback. We need to ignore ALL collapse
// callbacks during programmatic operations to prevent cascade effects.
const operationInProgressRef = useRef(false);
// Toggle handlers - block all collapse callbacks during the operation
const handleToggleNotesPanel = useCallback(() => {
operationInProgressRef.current = true;
setShowNotesPanel(!showNotesPanel);
// Reset after panel animations complete (useEffect + library animations)
setTimeout(() => {
operationInProgressRef.current = false;
}, 150);
}, [showNotesPanel, setShowNotesPanel]);
const handleToggleStatsPanel = useCallback(() => {
operationInProgressRef.current = true;
setShowStatsPanel(!showStatsPanel);
// Reset after panel animations complete (useEffect + library animations)
setTimeout(() => {
operationInProgressRef.current = false;
}, 150);
}, [showStatsPanel, setShowStatsPanel]);
// Collapse handlers - ignore ALL collapse events during programmatic operations.
// This prevents cascade collapses when expanding one panel causes the library
// to auto-collapse another panel for space.
const handleNotesCollapse = useCallback(() => {
if (!operationInProgressRef.current) {
setShowNotesPanel(false);
}
}, [setShowNotesPanel]);
const handleStatsCollapse = useCallback(() => {
if (!operationInProgressRef.current) {
setShowStatsPanel(false);
}
}, [setShowStatsPanel]);
// Expand handlers - also ignore during programmatic operations
const handleNotesExpand = useCallback(() => {
if (!operationInProgressRef.current && !showNotesPanel) {
setShowNotesPanel(true);
}
}, [showNotesPanel, setShowNotesPanel]);
const handleStatsExpand = useCallback(() => {
if (!operationInProgressRef.current && !showStatsPanel) {
setShowStatsPanel(true);
}
}, [showStatsPanel, setShowStatsPanel]);
// Sync notes panel collapsed state with preferences
useEffect(() => {
const notesPanel = notesPanelRef.current;
if (!notesPanel) return;
if (showNotesPanel) {
// Expand panel first, then resize to preferred size
// Use requestAnimationFrame to ensure DOM has updated before resize
notesPanel.expand(MIN_NOTES_SIZE_PERCENT);
requestAnimationFrame(() => {
// Double-check panel is expanded and resize to max
if (!notesPanel.isCollapsed()) {
notesPanel.resize(MAX_NOTES_SIZE_PERCENT);
}
// Verify size after another frame
requestAnimationFrame(() => {
const size = notesPanel.getSize();
if (size < MIN_NOTES_SIZE_PERCENT) {
notesPanel.resize(MAX_NOTES_SIZE_PERCENT);
}
});
});
} else {
// Collapse panel
notesPanel.resize(COLLAPSED_SIZE_PERCENT);
notesPanel.collapse();
requestAnimationFrame(() => {
if (!notesPanel.isCollapsed() || notesPanel.getSize() > COLLAPSED_SIZE_PERCENT + 1) {
notesPanel.collapse();
}
});
}
}, [showNotesPanel]);
// Sync stats panel collapsed state with preferences
useEffect(() => {
const statsPanel = statsPanelRef.current;
if (!statsPanel) return;
if (showStatsPanel) {
// Expand panel first, then resize to preferred size
statsPanel.expand(MIN_STATS_SIZE_PERCENT);
requestAnimationFrame(() => {
if (!statsPanel.isCollapsed()) {
statsPanel.resize(MAX_STATS_SIZE_PERCENT);
}
requestAnimationFrame(() => {
const size = statsPanel.getSize();
if (size < MIN_STATS_SIZE_PERCENT) {
statsPanel.resize(MAX_STATS_SIZE_PERCENT);
}
});
});
} else {
// Collapse panel
statsPanel.resize(COLLAPSED_SIZE_PERCENT);
statsPanel.collapse();
}
}, [showStatsPanel]);
return {
notesPanelRef,
statsPanelRef,
handleToggleNotesPanel,
handleToggleStatsPanel,
handleNotesCollapse,
handleStatsCollapse,
handleNotesExpand,
handleStatsExpand,
};
}

View File

@@ -106,5 +106,11 @@ export const ButtonVariant = {
SECONDARY: 'secondary',
} as const;
// Small square icon button sizing (used in panel headers)
export const buttonSize = {
/** Small square icon button (28px) */
iconSm: 'h-7 w-7 p-0',
} as const;
// External link security attribute (prevents tab-nabbing)
export const EXTERNAL_LINK_REL = 'noopener noreferrer' as const;

View File

@@ -9,7 +9,6 @@
import { AnimatePresence } from 'framer-motion';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ImperativePanelHandle } from 'react-resizable-panels';
import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
@@ -35,8 +34,10 @@ import {
MAX_NOTES_SIZE_PERCENT,
MAX_STATS_SIZE_PERCENT,
MIN_NOTES_SIZE_PERCENT,
MIN_STATS_SIZE_PERCENT,
usePanelPreferences,
} from '@/hooks/use-panel-preferences';
import { useRecordingPanels } from '@/hooks/use-recording-panels';
import { useRecordingSession } from '@/hooks/use-recording-session';
import { preferences } from '@/lib/preferences';
import { buildSpeakerNameMap } from '@/lib/speaker-utils';
@@ -161,9 +162,22 @@ export default function RecordingPage() {
}
}, []);
// Panel refs for imperative collapse/expand
const notesPanelRef = useRef<ImperativePanelHandle>(null);
const statsPanelRef = useRef<ImperativePanelHandle>(null);
// Panel refs, toggle handlers, and collapse/expand callbacks
const {
notesPanelRef,
statsPanelRef,
handleToggleNotesPanel,
handleToggleStatsPanel,
handleNotesCollapse,
handleStatsCollapse,
handleNotesExpand,
handleStatsExpand,
} = useRecordingPanels({
showNotesPanel,
showStatsPanel,
setShowNotesPanel,
setShowStatsPanel,
});
const shouldVirtualizeTranscript = filteredSegments.length > TRANSCRIPT_VIRTUALIZE_THRESHOLD;
const transcriptVirtualizer = useVirtualizer({
@@ -210,29 +224,6 @@ export default function RecordingPage() {
scrollElement.scrollTop = scrollElement.scrollHeight;
}, [segments.length, partialText, recordingState]);
// Sync panel collapsed state with preferences
useEffect(() => {
const notesPanel = notesPanelRef.current;
if (notesPanel) {
if (showNotesPanel && notesPanel.isCollapsed()) {
notesPanel.expand();
} else if (!showNotesPanel && !notesPanel.isCollapsed()) {
notesPanel.collapse();
}
}
}, [showNotesPanel]);
useEffect(() => {
const statsPanel = statsPanelRef.current;
if (statsPanel) {
if (showStatsPanel && statsPanel.isCollapsed()) {
statsPanel.expand();
} else if (!showStatsPanel && !statsPanel.isCollapsed()) {
statsPanel.collapse();
}
}
}, [showStatsPanel]);
// Navigate after stop
const handleStopRecording = async () => {
await stopRecording();
@@ -387,8 +378,8 @@ export default function RecordingPage() {
maxSize={MAX_NOTES_SIZE_PERCENT}
collapsible
collapsedSize={COLLAPSED_SIZE_PERCENT}
onCollapse={() => setShowNotesPanel(false)}
onExpand={() => setShowNotesPanel(true)}
onCollapse={handleNotesCollapse}
onExpand={handleNotesExpand}
onResize={setNotesPanelSize}
>
<NotesPanel
@@ -398,6 +389,7 @@ export default function RecordingPage() {
isRecording={recordingState === 'recording'}
notes={notes}
onNotesChange={setNotes}
onTogglePanel={handleToggleNotesPanel}
/>
</ResizablePanel>
</>
@@ -412,12 +404,12 @@ export default function RecordingPage() {
id="stats"
order={3}
defaultSize={showStatsPanel ? statsPanelSize : COLLAPSED_SIZE_PERCENT}
minSize={COLLAPSED_SIZE_PERCENT}
minSize={MIN_STATS_SIZE_PERCENT}
maxSize={MAX_STATS_SIZE_PERCENT}
collapsible
collapsedSize={COLLAPSED_SIZE_PERCENT}
onCollapse={() => setShowStatsPanel(false)}
onExpand={() => setShowStatsPanel(true)}
onCollapse={handleStatsCollapse}
onExpand={handleStatsExpand}
onResize={setStatsPanelSize}
>
<StatsPanel
@@ -430,6 +422,7 @@ export default function RecordingPage() {
isVadActive={isVadActive}
audioLevel={audioLevel}
speakerNameMap={speakerNameMap}
onTogglePanel={handleToggleStatsPanel}
/>
</ResizablePanel>
</>

View File

@@ -27,7 +27,10 @@
}
},
"include": [
"client/src/components"
"client/src",
"src/",
"clinet/src-tauri"
],
"ignore": {
"useGitignore": true,

View File

@@ -67,8 +67,13 @@ def _load_watchfiles() -> tuple[type[_WatchfilesPythonFilter], Callable[..., obj
def run_server() -> None:
"""Start the gRPC server process."""
subprocess.run([sys.executable, "-m", "noteflow.grpc.server"], check=False)
"""Start the gRPC server process.
Uses os.execvp() to replace this process with the server, avoiding
nested subprocess spawning that causes zombie processes when watchfiles
restarts on file changes.
"""
os.execvp(sys.executable, [sys.executable, "-m", "noteflow.grpc.server"])
def main() -> None:

View File

@@ -11,11 +11,16 @@ from typing import TYPE_CHECKING
from opentelemetry.trace import Span
from noteflow.infrastructure.logging import get_logger
from ._refinement import NoDiarizationAudioError
from ._types import DIARIZATION_TIMEOUT_SECONDS
if TYPE_CHECKING:
from ._job_validation import DiarizationJobContext
logger = get_logger(__name__)
def _set_diarization_span_attributes(span: Span, ctx: DiarizationJobContext) -> None:
"""Set initial span attributes for diarization job tracing."""
@@ -74,6 +79,19 @@ async def _execute_diarization_with_span(
span.set_attribute("diarization.cancelled", True)
await ctx.host.handle_job_cancelled(ctx.job_id, ctx.job, ctx.meeting_id)
raise # Re-raise to propagate cancellation
except NoDiarizationAudioError as exc:
# Expected for simulated transcripts or meetings without audio capture.
# Mark as completed with 0 updates rather than failed.
span.set_attribute("diarization.skipped", True)
span.set_attribute("diarization.skip_reason", str(exc))
logger.info(
"Diarization skipped for meeting %s: %s",
ctx.meeting_id,
exc,
)
# Complete the job with 0 updates - this is a valid "nothing to do" state
await ctx.host.update_job_completed(ctx.job_id, ctx.job, 0, [])
span.add_event("job_completed_no_audio")
# INTENTIONAL BROAD HANDLER: Job error boundary
# - Diarization can fail in many ways (model errors, audio issues, etc.)
# - Must capture any failure and update job status