👁️
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

prosemirror editor draft pt1

+1190
+195
package-lock.json
··· 27 27 "comlink": "^4.4.2", 28 28 "lucide-react": "^0.544.0", 29 29 "minisearch": "^7.2.0", 30 + "prosemirror-commands": "^1.7.1", 31 + "prosemirror-history": "^1.5.0", 32 + "prosemirror-inputrules": "^1.5.1", 33 + "prosemirror-keymap": "^1.2.3", 34 + "prosemirror-markdown": "^1.13.2", 35 + "prosemirror-model": "^1.25.4", 36 + "prosemirror-state": "^1.4.4", 37 + "prosemirror-view": "^1.41.4", 30 38 "react": "^19.2.0", 31 39 "react-dom": "^19.2.0", 32 40 "recharts": "^3.6.0", ··· 4708 4716 "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", 4709 4717 "license": "MIT" 4710 4718 }, 4719 + "node_modules/@types/linkify-it": { 4720 + "version": "5.0.0", 4721 + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", 4722 + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", 4723 + "license": "MIT" 4724 + }, 4725 + "node_modules/@types/markdown-it": { 4726 + "version": "14.1.2", 4727 + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", 4728 + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", 4729 + "license": "MIT", 4730 + "dependencies": { 4731 + "@types/linkify-it": "^5", 4732 + "@types/mdurl": "^2" 4733 + } 4734 + }, 4735 + "node_modules/@types/mdurl": { 4736 + "version": "2.0.0", 4737 + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", 4738 + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", 4739 + "license": "MIT" 4740 + }, 4711 4741 "node_modules/@types/node": { 4712 4742 "version": "22.18.12", 4713 4743 "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.12.tgz", ··· 6875 6905 "url": "https://opencollective.com/parcel" 6876 6906 } 6877 6907 }, 6908 + "node_modules/linkify-it": { 6909 + "version": "5.0.0", 6910 + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", 6911 + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", 6912 + "license": "MIT", 6913 + "dependencies": { 6914 + "uc.micro": "^2.0.0" 6915 + } 6916 + }, 6878 6917 "node_modules/loupe": { 6879 6918 "version": "3.2.1", 6880 6919 "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", ··· 6919 6958 "@jridgewell/sourcemap-codec": "^1.5.5" 6920 6959 } 6921 6960 }, 6961 + "node_modules/markdown-it": { 6962 + "version": "14.1.0", 6963 + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", 6964 + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", 6965 + "license": "MIT", 6966 + "dependencies": { 6967 + "argparse": "^2.0.1", 6968 + "entities": "^4.4.0", 6969 + "linkify-it": "^5.0.0", 6970 + "mdurl": "^2.0.0", 6971 + "punycode.js": "^2.3.1", 6972 + "uc.micro": "^2.1.0" 6973 + }, 6974 + "bin": { 6975 + "markdown-it": "bin/markdown-it.mjs" 6976 + } 6977 + }, 6978 + "node_modules/markdown-it/node_modules/argparse": { 6979 + "version": "2.0.1", 6980 + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", 6981 + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", 6982 + "license": "Python-2.0" 6983 + }, 6922 6984 "node_modules/mdn-data": { 6923 6985 "version": "2.12.2", 6924 6986 "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", 6925 6987 "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", 6926 6988 "dev": true, 6927 6989 "license": "CC0-1.0" 6990 + }, 6991 + "node_modules/mdurl": { 6992 + "version": "2.0.0", 6993 + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", 6994 + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", 6995 + "license": "MIT" 6928 6996 }, 6929 6997 "node_modules/merge2": { 6930 6998 "version": "1.4.1", ··· 7139 7207 "url": "https://github.com/fb55/nth-check?sponsor=1" 7140 7208 } 7141 7209 }, 7210 + "node_modules/orderedmap": { 7211 + "version": "2.1.1", 7212 + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", 7213 + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", 7214 + "license": "MIT" 7215 + }, 7142 7216 "node_modules/parse5": { 7143 7217 "version": "7.3.0", 7144 7218 "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", ··· 7346 7420 "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" 7347 7421 } 7348 7422 }, 7423 + "node_modules/prosemirror-commands": { 7424 + "version": "1.7.1", 7425 + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", 7426 + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", 7427 + "license": "MIT", 7428 + "dependencies": { 7429 + "prosemirror-model": "^1.0.0", 7430 + "prosemirror-state": "^1.0.0", 7431 + "prosemirror-transform": "^1.10.2" 7432 + } 7433 + }, 7434 + "node_modules/prosemirror-history": { 7435 + "version": "1.5.0", 7436 + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", 7437 + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", 7438 + "license": "MIT", 7439 + "dependencies": { 7440 + "prosemirror-state": "^1.2.2", 7441 + "prosemirror-transform": "^1.0.0", 7442 + "prosemirror-view": "^1.31.0", 7443 + "rope-sequence": "^1.3.0" 7444 + } 7445 + }, 7446 + "node_modules/prosemirror-inputrules": { 7447 + "version": "1.5.1", 7448 + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", 7449 + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", 7450 + "license": "MIT", 7451 + "dependencies": { 7452 + "prosemirror-state": "^1.0.0", 7453 + "prosemirror-transform": "^1.0.0" 7454 + } 7455 + }, 7456 + "node_modules/prosemirror-keymap": { 7457 + "version": "1.2.3", 7458 + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", 7459 + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", 7460 + "license": "MIT", 7461 + "dependencies": { 7462 + "prosemirror-state": "^1.0.0", 7463 + "w3c-keyname": "^2.2.0" 7464 + } 7465 + }, 7466 + "node_modules/prosemirror-markdown": { 7467 + "version": "1.13.2", 7468 + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz", 7469 + "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==", 7470 + "license": "MIT", 7471 + "dependencies": { 7472 + "@types/markdown-it": "^14.0.0", 7473 + "markdown-it": "^14.0.0", 7474 + "prosemirror-model": "^1.25.0" 7475 + } 7476 + }, 7477 + "node_modules/prosemirror-model": { 7478 + "version": "1.25.4", 7479 + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", 7480 + "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", 7481 + "license": "MIT", 7482 + "dependencies": { 7483 + "orderedmap": "^2.0.0" 7484 + } 7485 + }, 7486 + "node_modules/prosemirror-state": { 7487 + "version": "1.4.4", 7488 + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", 7489 + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", 7490 + "license": "MIT", 7491 + "dependencies": { 7492 + "prosemirror-model": "^1.0.0", 7493 + "prosemirror-transform": "^1.0.0", 7494 + "prosemirror-view": "^1.27.0" 7495 + } 7496 + }, 7497 + "node_modules/prosemirror-transform": { 7498 + "version": "1.10.5", 7499 + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz", 7500 + "integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==", 7501 + "license": "MIT", 7502 + "dependencies": { 7503 + "prosemirror-model": "^1.21.0" 7504 + } 7505 + }, 7506 + "node_modules/prosemirror-view": { 7507 + "version": "1.41.4", 7508 + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz", 7509 + "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==", 7510 + "license": "MIT", 7511 + "dependencies": { 7512 + "prosemirror-model": "^1.20.0", 7513 + "prosemirror-state": "^1.0.0", 7514 + "prosemirror-transform": "^1.1.0" 7515 + } 7516 + }, 7349 7517 "node_modules/punycode": { 7350 7518 "version": "2.3.1", 7351 7519 "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", ··· 7356 7524 "node": ">=6" 7357 7525 } 7358 7526 }, 7527 + "node_modules/punycode.js": { 7528 + "version": "2.3.1", 7529 + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", 7530 + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", 7531 + "license": "MIT", 7532 + "engines": { 7533 + "node": ">=6" 7534 + } 7535 + }, 7359 7536 "node_modules/pure-rand": { 7360 7537 "version": "7.0.1", 7361 7538 "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", ··· 7612 7789 "@rollup/rollup-win32-x64-msvc": "4.52.5", 7613 7790 "fsevents": "~2.3.2" 7614 7791 } 7792 + }, 7793 + "node_modules/rope-sequence": { 7794 + "version": "1.3.4", 7795 + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", 7796 + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", 7797 + "license": "MIT" 7615 7798 }, 7616 7799 "node_modules/rou3": { 7617 7800 "version": "0.7.10", ··· 8227 8410 "node": ">=14.17" 8228 8411 } 8229 8412 }, 8413 + "node_modules/uc.micro": { 8414 + "version": "2.1.0", 8415 + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", 8416 + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", 8417 + "license": "MIT" 8418 + }, 8230 8419 "node_modules/ufo": { 8231 8420 "version": "1.6.1", 8232 8421 "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", ··· 8655 8844 "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", 8656 8845 "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", 8657 8846 "dev": true, 8847 + "license": "MIT" 8848 + }, 8849 + "node_modules/w3c-keyname": { 8850 + "version": "2.2.8", 8851 + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", 8852 + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", 8658 8853 "license": "MIT" 8659 8854 }, 8660 8855 "node_modules/w3c-xmlserializer": {
+8
package.json
··· 41 41 "comlink": "^4.4.2", 42 42 "lucide-react": "^0.544.0", 43 43 "minisearch": "^7.2.0", 44 + "prosemirror-commands": "^1.7.1", 45 + "prosemirror-history": "^1.5.0", 46 + "prosemirror-inputrules": "^1.5.1", 47 + "prosemirror-keymap": "^1.2.3", 48 + "prosemirror-markdown": "^1.13.2", 49 + "prosemirror-model": "^1.25.4", 50 + "prosemirror-state": "^1.4.4", 51 + "prosemirror-view": "^1.41.4", 44 52 "react": "^19.2.0", 45 53 "react-dom": "^19.2.0", 46 54 "recharts": "^3.6.0",
+148
src/components/deck/PrimerSectionPM.tsx
··· 1 + import { ChevronDown, ChevronUp, Pencil } from "lucide-react"; 2 + import { useState } from "react"; 3 + import { DocRenderer } from "@/components/richtext/DocRenderer"; 4 + import { ProseMirrorEditor } from "@/components/richtext/ProseMirrorEditor"; 5 + import { type PMDocJSON, useProseMirror } from "@/lib/useProseMirror"; 6 + 7 + interface PrimerSectionPMProps { 8 + initialDoc?: PMDocJSON; 9 + onSave?: (doc: PMDocJSON) => void; 10 + isSaving?: boolean; 11 + readOnly?: boolean; 12 + } 13 + 14 + const COLLAPSED_LINES = 8; 15 + const LINE_HEIGHT = 1.5; 16 + 17 + function getDocText(doc: PMDocJSON | undefined): string { 18 + if (!doc?.content) return ""; 19 + return doc.content 20 + .map((block) => { 21 + if (block.type === "paragraph" && block.content) { 22 + return block.content.map((node) => node.text ?? "").join(""); 23 + } 24 + return ""; 25 + }) 26 + .join("\n"); 27 + } 28 + 29 + export function PrimerSectionPM({ 30 + initialDoc, 31 + onSave, 32 + isSaving, 33 + readOnly = false, 34 + }: PrimerSectionPMProps) { 35 + const [isEditing, setIsEditing] = useState(false); 36 + const [isExpanded, setIsExpanded] = useState(false); 37 + 38 + const { doc, docJSON, onChange, isDirty } = useProseMirror({ 39 + initialDoc, 40 + onSave, 41 + saveDebounceMs: 1500, 42 + }); 43 + 44 + const plainText = getDocText(docJSON); 45 + const hasContent = plainText.trim().length > 0; 46 + const lineCount = plainText.split("\n").length; 47 + const needsTruncation = lineCount > COLLAPSED_LINES; 48 + 49 + if (isEditing && !readOnly) { 50 + return ( 51 + <div className="space-y-3"> 52 + <ProseMirrorEditor 53 + defaultValue={doc} 54 + onChange={onChange} 55 + placeholder="Write about your deck's strategy, key combos, card choices..." 56 + /> 57 + <div className="flex items-center justify-between"> 58 + <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> 59 + {isSaving && <span>Saving...</span>} 60 + {!isSaving && isDirty && <span>Unsaved changes</span>} 61 + {!isSaving && !isDirty && hasContent && <span>Saved</span>} 62 + </div> 63 + <button 64 + type="button" 65 + onClick={() => setIsEditing(false)} 66 + className="px-3 py-1.5 text-sm font-medium rounded-md bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-gray-300" 67 + > 68 + Done 69 + </button> 70 + </div> 71 + </div> 72 + ); 73 + } 74 + 75 + if (!hasContent && readOnly) { 76 + return null; 77 + } 78 + 79 + if (!hasContent) { 80 + return ( 81 + <button 82 + type="button" 83 + onClick={() => setIsEditing(true)} 84 + className="text-sm text-gray-400 dark:text-gray-500 hover:text-blue-600 dark:hover:text-blue-400 italic" 85 + > 86 + Add a description... 87 + </button> 88 + ); 89 + } 90 + 91 + return ( 92 + <div> 93 + <div className="relative"> 94 + <div 95 + className={ 96 + !isExpanded && needsTruncation ? "overflow-hidden" : undefined 97 + } 98 + style={ 99 + !isExpanded && needsTruncation 100 + ? { maxHeight: `${COLLAPSED_LINES * LINE_HEIGHT}em` } 101 + : undefined 102 + } 103 + > 104 + <DocRenderer 105 + doc={docJSON} 106 + className="text-gray-700 dark:text-gray-300" 107 + /> 108 + </div> 109 + 110 + {needsTruncation && !isExpanded && ( 111 + <div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-white dark:from-slate-900 to-transparent pointer-events-none" /> 112 + )} 113 + </div> 114 + 115 + <div className="flex items-center gap-2 mt-2"> 116 + {needsTruncation && ( 117 + <button 118 + type="button" 119 + onClick={() => setIsExpanded(!isExpanded)} 120 + className="inline-flex items-center gap-1 px-2 py-1 text-sm font-medium rounded-md bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-gray-300" 121 + > 122 + {isExpanded ? ( 123 + <> 124 + <ChevronUp className="w-4 h-4" /> 125 + Show less 126 + </> 127 + ) : ( 128 + <> 129 + <ChevronDown className="w-4 h-4" /> 130 + Show more 131 + </> 132 + )} 133 + </button> 134 + )} 135 + {!readOnly && ( 136 + <button 137 + type="button" 138 + onClick={() => setIsEditing(true)} 139 + className="inline-flex items-center gap-1 px-2 py-1 text-sm font-medium rounded-md bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-gray-300" 140 + > 141 + <Pencil className="w-4 h-4" /> 142 + Edit 143 + </button> 144 + )} 145 + </div> 146 + </div> 147 + ); 148 + }
+176
src/components/richtext/DocRenderer.tsx
··· 1 + import type { ReactNode } from "react"; 2 + 3 + /** 4 + * ProseMirror document JSON structure types 5 + */ 6 + interface PMNode { 7 + type: string; 8 + content?: PMNode[]; 9 + text?: string; 10 + marks?: PMMark[]; 11 + attrs?: Record<string, unknown>; 12 + } 13 + 14 + interface PMMark { 15 + type: string; 16 + attrs?: Record<string, unknown>; 17 + } 18 + 19 + export interface DocRendererProps { 20 + doc: PMNode; 21 + className?: string; 22 + } 23 + 24 + /** 25 + * Renders a ProseMirror document JSON structure to React elements. 26 + * Used for read-only display of rich text content. 27 + */ 28 + export function DocRenderer({ doc, className }: DocRendererProps) { 29 + if (!doc || doc.type !== "doc") { 30 + return null; 31 + } 32 + 33 + return ( 34 + <div className={className}> 35 + {doc.content?.map((node, i) => ( 36 + // biome-ignore lint/suspicious/noArrayIndexKey: immutable doc structure 37 + <BlockNode key={i} node={node} /> 38 + ))} 39 + </div> 40 + ); 41 + } 42 + 43 + function BlockNode({ node }: { node: PMNode }): ReactNode { 44 + switch (node.type) { 45 + case "paragraph": 46 + return ( 47 + <p> 48 + {node.content?.map((child, i) => ( 49 + // biome-ignore lint/suspicious/noArrayIndexKey: immutable doc structure 50 + <InlineNode key={i} node={child} /> 51 + ))} 52 + </p> 53 + ); 54 + 55 + case "heading": { 56 + const level = (node.attrs?.level as number) ?? 1; 57 + const clampedLevel = Math.min(Math.max(level, 1), 6); 58 + const content = node.content?.map((child, i) => ( 59 + // biome-ignore lint/suspicious/noArrayIndexKey: immutable doc structure 60 + <InlineNode key={i} node={child} /> 61 + )); 62 + switch (clampedLevel) { 63 + case 1: 64 + return <h1>{content}</h1>; 65 + case 2: 66 + return <h2>{content}</h2>; 67 + case 3: 68 + return <h3>{content}</h3>; 69 + case 4: 70 + return <h4>{content}</h4>; 71 + case 5: 72 + return <h5>{content}</h5>; 73 + case 6: 74 + return <h6>{content}</h6>; 75 + default: 76 + return <h1>{content}</h1>; 77 + } 78 + } 79 + 80 + case "code_block": 81 + return ( 82 + <pre className="bg-gray-100 dark:bg-slate-800 p-3 rounded-lg overflow-x-auto"> 83 + <code>{node.content?.map((child) => child.text).join("") ?? ""}</code> 84 + </pre> 85 + ); 86 + 87 + case "blockquote": 88 + return ( 89 + <blockquote className="border-l-4 border-gray-300 dark:border-slate-600 pl-4 italic"> 90 + {node.content?.map((child, i) => ( 91 + // biome-ignore lint/suspicious/noArrayIndexKey: immutable doc structure 92 + <BlockNode key={i} node={child} /> 93 + ))} 94 + </blockquote> 95 + ); 96 + 97 + case "horizontal_rule": 98 + return <hr className="border-gray-300 dark:border-slate-600 my-4" />; 99 + 100 + case "hard_break": 101 + return <br />; 102 + 103 + default: 104 + // Unknown block type - skip gracefully 105 + return null; 106 + } 107 + } 108 + 109 + function InlineNode({ node }: { node: PMNode }): ReactNode { 110 + if (node.type === "text") { 111 + let content: ReactNode = node.text ?? ""; 112 + 113 + // Apply marks in order 114 + for (const mark of node.marks ?? []) { 115 + content = applyMark(content, mark); 116 + } 117 + 118 + return content; 119 + } 120 + 121 + if (node.type === "hard_break") { 122 + return <br />; 123 + } 124 + 125 + if (node.type === "mention") { 126 + const handle = node.attrs?.handle as string; 127 + return ( 128 + <span className="inline-flex items-center px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 text-sm font-medium"> 129 + @{handle} 130 + </span> 131 + ); 132 + } 133 + 134 + // Future: handle cardRef, etc. 135 + // if (node.type === "cardRef") { 136 + // return <CardRefChip oracleId={node.attrs.oracleId} name={node.attrs.displayName} />; 137 + // } 138 + 139 + // Unknown inline type - skip gracefully 140 + return null; 141 + } 142 + 143 + function applyMark(content: ReactNode, mark: PMMark): ReactNode { 144 + switch (mark.type) { 145 + case "strong": 146 + return <strong>{content}</strong>; 147 + 148 + case "em": 149 + return <em>{content}</em>; 150 + 151 + case "code": 152 + return ( 153 + <code className="bg-gray-100 dark:bg-slate-800 px-1 rounded font-mono text-sm"> 154 + {content} 155 + </code> 156 + ); 157 + 158 + case "link": { 159 + const href = mark.attrs?.href as string; 160 + return ( 161 + <a 162 + href={href} 163 + className="text-blue-600 dark:text-blue-400 hover:underline" 164 + target="_blank" 165 + rel="noopener noreferrer" 166 + > 167 + {content} 168 + </a> 169 + ); 170 + } 171 + 172 + default: 173 + // Unknown mark - return content unchanged 174 + return content; 175 + } 176 + }
+100
src/components/richtext/ProseMirrorEditor.tsx
··· 1 + import { baseKeymap, toggleMark } from "prosemirror-commands"; 2 + import { history, redo, undo } from "prosemirror-history"; 3 + import { keymap } from "prosemirror-keymap"; 4 + import type { Node as ProseMirrorNode } from "prosemirror-model"; 5 + import { EditorState } from "prosemirror-state"; 6 + import { EditorView } from "prosemirror-view"; 7 + import { useCallback, useEffect, useRef, useState } from "react"; 8 + import { buildInputRules } from "./inputRules"; 9 + import { createUpdatePlugin } from "./plugins"; 10 + import { schema } from "./schema"; 11 + import { Toolbar } from "./Toolbar"; 12 + 13 + export interface ProseMirrorEditorProps { 14 + defaultValue?: ProseMirrorNode; 15 + onChange?: (doc: ProseMirrorNode) => void; 16 + placeholder?: string; 17 + className?: string; 18 + showToolbar?: boolean; 19 + } 20 + 21 + export function ProseMirrorEditor({ 22 + defaultValue, 23 + onChange, 24 + placeholder = "Write something...", 25 + className, 26 + showToolbar = true, 27 + }: ProseMirrorEditorProps) { 28 + const containerRef = useRef<HTMLDivElement>(null); 29 + const viewRef = useRef<EditorView | null>(null); 30 + const onChangeRef = useRef(onChange); 31 + onChangeRef.current = onChange; 32 + 33 + // Force toolbar re-render on selection/state changes 34 + const [, setUpdateCounter] = useState(0); 35 + const forceUpdate = useCallback(() => setUpdateCounter((c) => c + 1), []); 36 + 37 + // Capture initial values - don't recreate editor on prop changes 38 + const initialDocRef = useRef(defaultValue); 39 + const initialPlaceholderRef = useRef(placeholder); 40 + const forceUpdateRef = useRef(forceUpdate); 41 + forceUpdateRef.current = forceUpdate; 42 + 43 + useEffect(() => { 44 + if (!containerRef.current) return; 45 + 46 + const state = EditorState.create({ 47 + doc: 48 + initialDocRef.current ?? 49 + schema.node("doc", null, [schema.node("paragraph")]), 50 + schema, 51 + plugins: [ 52 + buildInputRules(schema), 53 + history(), 54 + keymap({ 55 + "Mod-z": undo, 56 + "Mod-Shift-z": redo, 57 + "Mod-y": redo, 58 + "Mod-b": toggleMark(schema.marks.strong), 59 + "Mod-i": toggleMark(schema.marks.em), 60 + "Mod-`": toggleMark(schema.marks.code), 61 + }), 62 + keymap(baseKeymap), 63 + // Trigger React re-render on state changes for toolbar updates 64 + createUpdatePlugin(() => forceUpdateRef.current()), 65 + ], 66 + }); 67 + 68 + const view = new EditorView(containerRef.current, { 69 + state, 70 + dispatchTransaction(tr) { 71 + const newState = view.state.apply(tr); 72 + view.updateState(newState); 73 + if (tr.docChanged) { 74 + onChangeRef.current?.(newState.doc); 75 + } 76 + }, 77 + attributes: { 78 + class: 79 + "prose dark:prose-invert prose-sm max-w-none focus:outline-none min-h-[8rem] p-3", 80 + "data-placeholder": initialPlaceholderRef.current, 81 + }, 82 + }); 83 + 84 + viewRef.current = view; 85 + 86 + return () => { 87 + view.destroy(); 88 + viewRef.current = null; 89 + }; 90 + }, []); 91 + 92 + return ( 93 + <div 94 + className={`prosemirror-editor border border-gray-300 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 overflow-hidden ${className ?? ""}`} 95 + > 96 + {showToolbar && <Toolbar view={viewRef.current} />} 97 + <div ref={containerRef} /> 98 + </div> 99 + ); 100 + }
+114
src/components/richtext/Toolbar.tsx
··· 1 + import { Bold, Code, Italic, Link } from "lucide-react"; 2 + import { toggleMark } from "prosemirror-commands"; 3 + import type { MarkType } from "prosemirror-model"; 4 + import type { EditorView } from "prosemirror-view"; 5 + 6 + interface ToolbarProps { 7 + view: EditorView | null; 8 + } 9 + 10 + export function Toolbar({ view }: ToolbarProps) { 11 + if (!view) return null; 12 + 13 + const { state } = view; 14 + const { schema } = state; 15 + 16 + const isMarkActive = (markType: MarkType) => { 17 + const { from, $from, to, empty } = state.selection; 18 + if (empty) { 19 + return !!markType.isInSet(state.storedMarks || $from.marks()); 20 + } 21 + return state.doc.rangeHasMark(from, to, markType); 22 + }; 23 + 24 + const toggleMarkCommand = (markType: MarkType) => { 25 + return () => { 26 + toggleMark(markType)(state, view.dispatch, view); 27 + view.focus(); 28 + }; 29 + }; 30 + 31 + const insertLink = () => { 32 + const { from, to } = state.selection; 33 + const selectedText = state.doc.textBetween(from, to); 34 + const url = prompt("Enter URL:", "https://"); 35 + if (!url) return; 36 + 37 + const linkMark = schema.marks.link.create({ href: url }); 38 + const tr = state.tr; 39 + 40 + if (selectedText) { 41 + tr.addMark(from, to, linkMark); 42 + } else { 43 + const linkText = prompt("Enter link text:", url) || url; 44 + tr.insertText(linkText, from); 45 + tr.addMark(from, from + linkText.length, linkMark); 46 + } 47 + 48 + view.dispatch(tr); 49 + view.focus(); 50 + }; 51 + 52 + return ( 53 + <div className="flex items-center gap-1 p-2 border-b border-gray-300 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50"> 54 + <ToolbarButton 55 + onClick={toggleMarkCommand(schema.marks.strong)} 56 + active={isMarkActive(schema.marks.strong)} 57 + title="Bold (Cmd+B)" 58 + > 59 + <Bold className="w-4 h-4" /> 60 + </ToolbarButton> 61 + <ToolbarButton 62 + onClick={toggleMarkCommand(schema.marks.em)} 63 + active={isMarkActive(schema.marks.em)} 64 + title="Italic (Cmd+I)" 65 + > 66 + <Italic className="w-4 h-4" /> 67 + </ToolbarButton> 68 + <ToolbarButton 69 + onClick={toggleMarkCommand(schema.marks.code)} 70 + active={isMarkActive(schema.marks.code)} 71 + title="Code (Cmd+`)" 72 + > 73 + <Code className="w-4 h-4" /> 74 + </ToolbarButton> 75 + <div className="w-px h-5 bg-gray-300 dark:bg-slate-600 mx-1" /> 76 + <ToolbarButton 77 + onClick={insertLink} 78 + active={isMarkActive(schema.marks.link)} 79 + title="Insert Link" 80 + > 81 + <Link className="w-4 h-4" /> 82 + </ToolbarButton> 83 + </div> 84 + ); 85 + } 86 + 87 + interface ToolbarButtonProps { 88 + onClick: () => void; 89 + active: boolean; 90 + title: string; 91 + children: React.ReactNode; 92 + } 93 + 94 + function ToolbarButton({ 95 + onClick, 96 + active, 97 + title, 98 + children, 99 + }: ToolbarButtonProps) { 100 + return ( 101 + <button 102 + type="button" 103 + onClick={onClick} 104 + title={title} 105 + className={`p-1.5 rounded transition-colors ${ 106 + active 107 + ? "bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400" 108 + : "hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-600 dark:text-gray-400" 109 + }`} 110 + > 111 + {children} 112 + </button> 113 + ); 114 + }
+105
src/components/richtext/inputRules.ts
··· 1 + import { 2 + InputRule, 3 + inputRules, 4 + textblockTypeInputRule, 5 + wrappingInputRule, 6 + } from "prosemirror-inputrules"; 7 + import type { MarkType, NodeType, Schema } from "prosemirror-model"; 8 + 9 + /** 10 + * Build input rules for markdown-style shortcuts 11 + */ 12 + export function buildInputRules(schema: Schema) { 13 + const rules: InputRule[] = []; 14 + 15 + // Bold: **text** 16 + // Lookbehind ensures we don't match inside an existing bold sequence 17 + // Pattern: not-star or line-start, then **, content, ** 18 + if (schema.marks.strong) { 19 + rules.push( 20 + markInputRule(/(?:^|[^*])(\*\*([^*]+)\*\*)$/, schema.marks.strong, 2), 21 + ); 22 + } 23 + 24 + // Italic: *text* 25 + // Must not be preceded by * (would be bold) or followed by * (incomplete bold) 26 + if (schema.marks.em) { 27 + rules.push(markInputRule(/(?:^|[^*])(\*([^*]+)\*)$/, schema.marks.em, 2)); 28 + } 29 + 30 + // Inline code: `text` 31 + if (schema.marks.code) { 32 + rules.push(markInputRule(/(?:^|[^`])(`([^`]+)`)$/, schema.marks.code, 2)); 33 + } 34 + 35 + // Code block: ``` at start of line 36 + if (schema.nodes.code_block) { 37 + rules.push( 38 + textblockTypeInputRule(/^```$/, schema.nodes.code_block as NodeType), 39 + ); 40 + } 41 + 42 + // Blockquote: > at start of line 43 + if (schema.nodes.blockquote) { 44 + rules.push( 45 + wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote as NodeType), 46 + ); 47 + } 48 + 49 + // Heading: # at start of line (levels 1-6) 50 + if (schema.nodes.heading) { 51 + for (let level = 1; level <= 6; level++) { 52 + const pattern = new RegExp(`^(#{${level}})\\s$`); 53 + rules.push( 54 + textblockTypeInputRule(pattern, schema.nodes.heading as NodeType, { 55 + level, 56 + }), 57 + ); 58 + } 59 + } 60 + 61 + return inputRules({ rules }); 62 + } 63 + 64 + /** 65 + * Create an input rule that applies a mark when a pattern matches. 66 + * 67 + * Based on the standard pattern from: 68 + * https://discuss.prosemirror.net/t/input-rules-for-wrapping-marks/537 69 + * 70 + * @param pattern - Regex with capture groups: group 1 = full match to delete, 71 + * group `textGroup` = the text to keep and mark 72 + * @param markType - The mark type to apply 73 + * @param textGroup - Which capture group contains the text (default 1) 74 + */ 75 + function markInputRule( 76 + pattern: RegExp, 77 + markType: MarkType, 78 + textGroup = 1, 79 + ): InputRule { 80 + return new InputRule(pattern, (state, match, start, _end) => { 81 + const fullMatch = match[1]; 82 + const text = match[textGroup]; 83 + if (!fullMatch || !text) return null; 84 + 85 + const tr = state.tr; 86 + 87 + // Calculate where the full match (including delimiters) starts 88 + const matchStart = start + match[0].indexOf(fullMatch); 89 + const matchEnd = matchStart + fullMatch.length; 90 + 91 + // Delete the matched text (including markers) 92 + tr.delete(matchStart, matchEnd); 93 + 94 + // Insert the text without markers 95 + tr.insertText(text, matchStart); 96 + 97 + // Apply the mark to the inserted text 98 + tr.addMark(matchStart, matchStart + text.length, markType.create()); 99 + 100 + // Remove stored mark so next typed text isn't marked 101 + tr.removeStoredMark(markType); 102 + 103 + return tr; 104 + }); 105 + }
+19
src/components/richtext/plugins.ts
··· 1 + import { Plugin, PluginKey } from "prosemirror-state"; 2 + import type { EditorView } from "prosemirror-view"; 3 + 4 + /** 5 + * Plugin that calls a callback on every state update. 6 + * Used to trigger React re-renders for toolbar state. 7 + */ 8 + export function createUpdatePlugin(onUpdate: (view: EditorView) => void) { 9 + return new Plugin({ 10 + key: new PluginKey("reactUpdate"), 11 + view() { 12 + return { 13 + update(view) { 14 + onUpdate(view); 15 + }, 16 + }; 17 + }, 18 + }); 19 + }
+41
src/components/richtext/schema.ts
··· 1 + import { schema as markdownSchema } from "prosemirror-markdown"; 2 + import { Schema } from "prosemirror-model"; 3 + 4 + /** 5 + * Extended schema adding custom inline nodes for deck primers. 6 + * 7 + * Extends prosemirror-markdown's schema with: 8 + * - mention: @username references 9 + * - (future) cardRef: [[Card Name]] references 10 + */ 11 + export const schema = new Schema({ 12 + nodes: markdownSchema.spec.nodes.addBefore("image", "mention", { 13 + inline: true, 14 + group: "inline", 15 + atom: true, // Treated as a single unit, not editable internally 16 + attrs: { 17 + handle: { default: "" }, 18 + }, 19 + toDOM(node) { 20 + return [ 21 + "span", 22 + { 23 + class: 24 + "inline-flex items-center px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 text-sm font-medium", 25 + "data-handle": node.attrs.handle, 26 + }, 27 + `@${node.attrs.handle}`, 28 + ]; 29 + }, 30 + parseDOM: [ 31 + { 32 + tag: "span.mention", 33 + getAttrs(dom) { 34 + if (typeof dom === "string") return false; 35 + return { handle: dom.getAttribute("data-handle") ?? "" }; 36 + }, 37 + }, 38 + ], 39 + }), 40 + marks: markdownSchema.spec.marks, 41 + });
+122
src/lib/useProseMirror.ts
··· 1 + import type { Node as ProseMirrorNode } from "prosemirror-model"; 2 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 3 + import { schema } from "@/components/richtext/schema"; 4 + import { useImperativeDebounce } from "./useDebounce"; 5 + 6 + /** 7 + * ProseMirror document JSON (what we store in the lexicon) 8 + */ 9 + export interface PMDocJSON { 10 + type: "doc"; 11 + content?: PMNodeJSON[]; 12 + } 13 + 14 + interface PMNodeJSON { 15 + type: string; 16 + content?: PMNodeJSON[]; 17 + text?: string; 18 + marks?: PMMarkJSON[]; 19 + attrs?: Record<string, unknown>; 20 + } 21 + 22 + interface PMMarkJSON { 23 + type: string; 24 + attrs?: Record<string, unknown>; 25 + } 26 + 27 + export interface UseProseMirrorOptions { 28 + initialDoc?: PMDocJSON; 29 + onSave?: (doc: PMDocJSON) => void | Promise<void>; 30 + saveDebounceMs?: number; 31 + } 32 + 33 + export interface UseProseMirrorResult { 34 + doc: ProseMirrorNode; 35 + docJSON: PMDocJSON; 36 + onChange: (newDoc: ProseMirrorNode) => void; 37 + isDirty: boolean; 38 + save: () => void; 39 + } 40 + 41 + function createEmptyDoc(): ProseMirrorNode { 42 + return schema.node("doc", null, [schema.node("paragraph")]); 43 + } 44 + 45 + function docFromJSON(json: PMDocJSON | undefined): ProseMirrorNode { 46 + if (!json) return createEmptyDoc(); 47 + try { 48 + return schema.nodeFromJSON(json); 49 + } catch { 50 + return createEmptyDoc(); 51 + } 52 + } 53 + 54 + function docToJSON(doc: ProseMirrorNode): PMDocJSON { 55 + return doc.toJSON() as PMDocJSON; 56 + } 57 + 58 + /** 59 + * React hook for managing ProseMirror editor state with debounced autosave. 60 + * 61 + * @warn Changing `initialDoc` resets the editor and discards unsaved edits. 62 + * Only pass a new value when you intend to reset (e.g., loading a different record). 63 + */ 64 + export function useProseMirror({ 65 + initialDoc, 66 + onSave, 67 + saveDebounceMs = 1500, 68 + }: UseProseMirrorOptions = {}): UseProseMirrorResult { 69 + // Track saved state as a node for efficient .eq() comparison 70 + const savedNodeRef = useRef<ProseMirrorNode>(docFromJSON(initialDoc)); 71 + const onSaveRef = useRef(onSave); 72 + onSaveRef.current = onSave; 73 + 74 + const [doc, setDoc] = useState<ProseMirrorNode>(() => 75 + docFromJSON(initialDoc), 76 + ); 77 + const [saveState, setSaveState] = useState<"saved" | "dirty">("saved"); 78 + 79 + const docJSON = useMemo(() => docToJSON(doc), [doc]); 80 + 81 + const debounce = useImperativeDebounce( 82 + doc, 83 + saveDebounceMs, 84 + (value: ProseMirrorNode) => { 85 + if (!value.eq(savedNodeRef.current) && onSaveRef.current) { 86 + onSaveRef.current(docToJSON(value)); 87 + savedNodeRef.current = value; 88 + } 89 + setSaveState("saved"); 90 + }, 91 + ); 92 + 93 + const onChange = useCallback( 94 + (newDoc: ProseMirrorNode) => { 95 + setDoc(newDoc); 96 + if (!newDoc.eq(savedNodeRef.current)) { 97 + setSaveState("dirty"); 98 + } 99 + debounce.update(newDoc); 100 + }, 101 + [debounce], 102 + ); 103 + 104 + const save = useCallback(() => { 105 + debounce.flush(); 106 + }, [debounce]); 107 + 108 + useEffect(() => { 109 + const newNode = docFromJSON(initialDoc); 110 + setDoc(newNode); 111 + savedNodeRef.current = newNode; 112 + setSaveState("saved"); 113 + }, [initialDoc]); 114 + 115 + return { 116 + doc, 117 + docJSON, 118 + onChange, 119 + isDirty: saveState === "dirty", 120 + save, 121 + }; 122 + }
+21
src/routeTree.gen.ts
··· 10 10 11 11 import { Route as rootRouteImport } from './routes/__root' 12 12 import { Route as SigninRouteImport } from './routes/signin' 13 + import { Route as PmDemoRouteImport } from './routes/pm-demo' 13 14 import { Route as IndexRouteImport } from './routes/index' 14 15 import { Route as CardsIndexRouteImport } from './routes/cards/index' 15 16 import { Route as UHandleRouteImport } from './routes/u/$handle' ··· 27 28 const SigninRoute = SigninRouteImport.update({ 28 29 id: '/signin', 29 30 path: '/signin', 31 + getParentRoute: () => rootRouteImport, 32 + } as any) 33 + const PmDemoRoute = PmDemoRouteImport.update({ 34 + id: '/pm-demo', 35 + path: '/pm-demo', 30 36 getParentRoute: () => rootRouteImport, 31 37 } as any) 32 38 const IndexRoute = IndexRouteImport.update({ ··· 98 104 99 105 export interface FileRoutesByFullPath { 100 106 '/': typeof IndexRoute 107 + '/pm-demo': typeof PmDemoRoute 101 108 '/signin': typeof SigninRoute 102 109 '/card/$id': typeof CardIdRoute 103 110 '/deck/new': typeof DeckNewRoute ··· 114 121 } 115 122 export interface FileRoutesByTo { 116 123 '/': typeof IndexRoute 124 + '/pm-demo': typeof PmDemoRoute 117 125 '/signin': typeof SigninRoute 118 126 '/card/$id': typeof CardIdRoute 119 127 '/deck/new': typeof DeckNewRoute ··· 129 137 export interface FileRoutesById { 130 138 __root__: typeof rootRouteImport 131 139 '/': typeof IndexRoute 140 + '/pm-demo': typeof PmDemoRoute 132 141 '/signin': typeof SigninRoute 133 142 '/card/$id': typeof CardIdRoute 134 143 '/deck/new': typeof DeckNewRoute ··· 147 156 fileRoutesByFullPath: FileRoutesByFullPath 148 157 fullPaths: 149 158 | '/' 159 + | '/pm-demo' 150 160 | '/signin' 151 161 | '/card/$id' 152 162 | '/deck/new' ··· 163 173 fileRoutesByTo: FileRoutesByTo 164 174 to: 165 175 | '/' 176 + | '/pm-demo' 166 177 | '/signin' 167 178 | '/card/$id' 168 179 | '/deck/new' ··· 177 188 id: 178 189 | '__root__' 179 190 | '/' 191 + | '/pm-demo' 180 192 | '/signin' 181 193 | '/card/$id' 182 194 | '/deck/new' ··· 194 206 } 195 207 export interface RootRouteChildren { 196 208 IndexRoute: typeof IndexRoute 209 + PmDemoRoute: typeof PmDemoRoute 197 210 SigninRoute: typeof SigninRoute 198 211 CardIdRoute: typeof CardIdRoute 199 212 DeckNewRoute: typeof DeckNewRoute ··· 212 225 path: '/signin' 213 226 fullPath: '/signin' 214 227 preLoaderRoute: typeof SigninRouteImport 228 + parentRoute: typeof rootRouteImport 229 + } 230 + '/pm-demo': { 231 + id: '/pm-demo' 232 + path: '/pm-demo' 233 + fullPath: '/pm-demo' 234 + preLoaderRoute: typeof PmDemoRouteImport 215 235 parentRoute: typeof rootRouteImport 216 236 } 217 237 '/': { ··· 336 356 337 357 const rootRouteChildren: RootRouteChildren = { 338 358 IndexRoute: IndexRoute, 359 + PmDemoRoute: PmDemoRoute, 339 360 SigninRoute: SigninRoute, 340 361 CardIdRoute: CardIdRoute, 341 362 DeckNewRoute: DeckNewRoute,
+112
src/routes/pm-demo.tsx
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + import { useState } from "react"; 3 + import { PrimerSectionPM } from "@/components/deck/PrimerSectionPM"; 4 + import type { PMDocJSON } from "@/lib/useProseMirror"; 5 + 6 + export const Route = createFileRoute("/pm-demo")({ 7 + component: ProseMirrorDemo, 8 + }); 9 + 10 + const SAMPLE_DOC: PMDocJSON = { 11 + type: "doc", 12 + content: [ 13 + { 14 + type: "paragraph", 15 + content: [ 16 + { type: "text", text: "This is a " }, 17 + { 18 + type: "text", 19 + text: "bold", 20 + marks: [{ type: "strong" }], 21 + }, 22 + { type: "text", text: " and " }, 23 + { 24 + type: "text", 25 + text: "italic", 26 + marks: [{ type: "em" }], 27 + }, 28 + { type: "text", text: " text demo." }, 29 + ], 30 + }, 31 + { 32 + type: "paragraph", 33 + content: [ 34 + { type: "text", text: "Try typing " }, 35 + { 36 + type: "text", 37 + text: "**bold**", 38 + marks: [{ type: "code" }], 39 + }, 40 + { type: "text", text: " or " }, 41 + { 42 + type: "text", 43 + text: "*italic*", 44 + marks: [{ type: "code" }], 45 + }, 46 + { type: "text", text: " to use markdown shortcuts!" }, 47 + ], 48 + }, 49 + ], 50 + }; 51 + 52 + function ProseMirrorDemo() { 53 + const [savedDoc, setSavedDoc] = useState<PMDocJSON | undefined>(SAMPLE_DOC); 54 + const [lastSaved, setLastSaved] = useState<string>(""); 55 + 56 + const handleSave = (doc: PMDocJSON) => { 57 + setSavedDoc(doc); 58 + setLastSaved(new Date().toLocaleTimeString()); 59 + console.log("Saved doc:", JSON.stringify(doc, null, 2)); 60 + }; 61 + 62 + return ( 63 + <div className="min-h-screen bg-white dark:bg-slate-900 p-8"> 64 + <div className="max-w-3xl mx-auto space-y-8"> 65 + <div> 66 + <h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2"> 67 + ProseMirror Editor Demo 68 + </h1> 69 + <p className="text-gray-600 dark:text-gray-400"> 70 + Testing the new WYSIWYG editor. Use toolbar buttons or markdown 71 + shortcuts. 72 + </p> 73 + </div> 74 + 75 + <div className="space-y-4"> 76 + <h2 className="text-lg font-semibold text-gray-900 dark:text-white"> 77 + Editor 78 + </h2> 79 + <PrimerSectionPM 80 + initialDoc={savedDoc} 81 + onSave={handleSave} 82 + readOnly={false} 83 + /> 84 + </div> 85 + 86 + <div className="space-y-4"> 87 + <h2 className="text-lg font-semibold text-gray-900 dark:text-white"> 88 + Read-only View 89 + </h2> 90 + <div className="p-4 border border-gray-200 dark:border-slate-700 rounded-lg bg-gray-50 dark:bg-slate-800/50"> 91 + <PrimerSectionPM initialDoc={savedDoc} readOnly /> 92 + </div> 93 + </div> 94 + 95 + {lastSaved && ( 96 + <p className="text-sm text-gray-500 dark:text-gray-400"> 97 + Last saved: {lastSaved} 98 + </p> 99 + )} 100 + 101 + <details className="text-sm"> 102 + <summary className="cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"> 103 + View document JSON 104 + </summary> 105 + <pre className="mt-2 p-4 bg-gray-100 dark:bg-slate-800 rounded overflow-auto text-xs"> 106 + {JSON.stringify(savedDoc, null, 2)} 107 + </pre> 108 + </details> 109 + </div> 110 + </div> 111 + ); 112 + }
+29
src/styles.css
··· 72 72 transform: translateX(100%); 73 73 } 74 74 } 75 + 76 + /* ProseMirror editor styles */ 77 + .prosemirror-editor .ProseMirror { 78 + outline: none; 79 + } 80 + 81 + .prosemirror-editor .ProseMirror p.is-editor-empty:first-child::before { 82 + content: attr(data-placeholder); 83 + float: left; 84 + color: var(--color-gray-400); 85 + pointer-events: none; 86 + height: 0; 87 + } 88 + 89 + .dark .prosemirror-editor .ProseMirror p.is-editor-empty:first-child::before { 90 + color: var(--color-gray-500); 91 + } 92 + 93 + /* Empty editor placeholder using data attribute */ 94 + .prosemirror-editor .ProseMirror:has(> p:only-child:empty)::before { 95 + content: attr(data-placeholder); 96 + color: var(--color-gray-400); 97 + pointer-events: none; 98 + position: absolute; 99 + } 100 + 101 + .dark .prosemirror-editor .ProseMirror:has(> p:only-child:empty)::before { 102 + color: var(--color-gray-500); 103 + }