👁️
5
fork

Configure Feed

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

vendor the prosemirror markdown schema

+275 -13
+275 -13
src/components/richtext/schema.ts
··· 1 - import { schema as markdownSchema } from "prosemirror-markdown"; 1 + import type { MarkSpec, NodeSpec } from "prosemirror-model"; 2 2 import { Schema } from "prosemirror-model"; 3 3 4 4 /** 5 - * Extended schema adding custom inline nodes for deck primers. 5 + * Vendored and customized schema based on prosemirror-markdown. 6 6 * 7 - * Extends prosemirror-markdown's schema with: 8 - * - mention: @username references 9 - * - (future) cardRef: [[Card Name]] references 7 + * Changes from upstream: 8 + * - Added Tailwind classes for dark mode support 9 + * - Removed image node (not needed for primers) 10 + * - Added custom inline nodes: mention, cardRef (future) 11 + * 12 + * We vendor this to have full control over styling and custom nodes. 10 13 */ 11 - export const schema = new Schema({ 12 - nodes: markdownSchema.spec.nodes.addBefore("image", "mention", { 14 + 15 + const nodes: Record<string, NodeSpec> = { 16 + doc: { 17 + content: "block+", 18 + }, 19 + 20 + paragraph: { 21 + content: "inline*", 22 + group: "block", 23 + parseDOM: [{ tag: "p" }], 24 + toDOM() { 25 + return ["p", 0]; 26 + }, 27 + }, 28 + 29 + blockquote: { 30 + content: "block+", 31 + group: "block", 32 + parseDOM: [{ tag: "blockquote" }], 33 + toDOM() { 34 + return [ 35 + "blockquote", 36 + { 37 + class: 38 + "border-l-4 border-gray-300 dark:border-slate-600 pl-4 my-2 text-gray-600 dark:text-gray-400 italic", 39 + }, 40 + 0, 41 + ]; 42 + }, 43 + }, 44 + 45 + horizontal_rule: { 46 + group: "block", 47 + parseDOM: [{ tag: "hr" }], 48 + toDOM() { 49 + return ["hr", { class: "my-4 border-gray-300 dark:border-slate-600" }]; 50 + }, 51 + }, 52 + 53 + heading: { 54 + attrs: { level: { default: 1 } }, 55 + content: "inline*", 56 + group: "block", 57 + defining: true, 58 + parseDOM: [ 59 + { tag: "h1", attrs: { level: 1 } }, 60 + { tag: "h2", attrs: { level: 2 } }, 61 + { tag: "h3", attrs: { level: 3 } }, 62 + { tag: "h4", attrs: { level: 4 } }, 63 + { tag: "h5", attrs: { level: 5 } }, 64 + { tag: "h6", attrs: { level: 6 } }, 65 + ], 66 + toDOM(node) { 67 + const level = node.attrs.level as number; 68 + const classes: Record<number, string> = { 69 + 1: "text-2xl font-bold mt-4 mb-2", 70 + 2: "text-xl font-bold mt-3 mb-2", 71 + 3: "text-lg font-semibold mt-3 mb-1", 72 + 4: "text-base font-semibold mt-2 mb-1", 73 + 5: "text-sm font-semibold mt-2 mb-1", 74 + 6: "text-sm font-medium mt-2 mb-1", 75 + }; 76 + return [`h${level}`, { class: classes[level] || classes[1] }, 0]; 77 + }, 78 + }, 79 + 80 + code_block: { 81 + content: "text*", 82 + group: "block", 83 + code: true, 84 + defining: true, 85 + marks: "", 86 + attrs: { params: { default: "" } }, 87 + parseDOM: [ 88 + { 89 + tag: "pre", 90 + preserveWhitespace: "full", 91 + getAttrs: (node) => ({ 92 + params: (node as HTMLElement).getAttribute("data-params") || "", 93 + }), 94 + }, 95 + ], 96 + toDOM(node) { 97 + return [ 98 + "pre", 99 + { 100 + class: 101 + "bg-gray-100 dark:bg-slate-800 rounded-lg p-3 my-2 overflow-x-auto", 102 + ...(node.attrs.params ? { "data-params": node.attrs.params } : {}), 103 + }, 104 + [ 105 + "code", 106 + { class: "font-mono text-sm text-gray-800 dark:text-gray-200" }, 107 + 0, 108 + ], 109 + ]; 110 + }, 111 + }, 112 + 113 + ordered_list: { 114 + content: "list_item+", 115 + group: "block", 116 + attrs: { order: { default: 1 }, tight: { default: false } }, 117 + parseDOM: [ 118 + { 119 + tag: "ol", 120 + getAttrs(dom) { 121 + return { 122 + order: (dom as HTMLElement).hasAttribute("start") 123 + ? Number((dom as HTMLElement).getAttribute("start")) 124 + : 1, 125 + tight: (dom as HTMLElement).hasAttribute("data-tight"), 126 + }; 127 + }, 128 + }, 129 + ], 130 + toDOM(node) { 131 + return [ 132 + "ol", 133 + { 134 + class: "list-decimal list-inside my-2 space-y-1", 135 + start: node.attrs.order === 1 ? null : node.attrs.order, 136 + "data-tight": node.attrs.tight ? "true" : null, 137 + }, 138 + 0, 139 + ]; 140 + }, 141 + }, 142 + 143 + bullet_list: { 144 + content: "list_item+", 145 + group: "block", 146 + attrs: { tight: { default: false } }, 147 + parseDOM: [ 148 + { 149 + tag: "ul", 150 + getAttrs: (dom) => ({ 151 + tight: (dom as HTMLElement).hasAttribute("data-tight"), 152 + }), 153 + }, 154 + ], 155 + toDOM(node) { 156 + return [ 157 + "ul", 158 + { 159 + class: "list-disc list-inside my-2 space-y-1", 160 + "data-tight": node.attrs.tight ? "true" : null, 161 + }, 162 + 0, 163 + ]; 164 + }, 165 + }, 166 + 167 + list_item: { 168 + content: "block+", 169 + defining: true, 170 + parseDOM: [{ tag: "li" }], 171 + toDOM() { 172 + return ["li", 0]; 173 + }, 174 + }, 175 + 176 + text: { 177 + group: "inline", 178 + }, 179 + 180 + hard_break: { 13 181 inline: true, 14 182 group: "inline", 15 - atom: true, // Treated as a single unit, not editable internally 183 + selectable: false, 184 + parseDOM: [{ tag: "br" }], 185 + toDOM() { 186 + return ["br"]; 187 + }, 188 + }, 189 + 190 + mention: { 191 + inline: true, 192 + group: "inline", 193 + atom: true, 16 194 attrs: { 17 195 handle: { default: "" }, 196 + did: { default: null }, 18 197 }, 19 198 toDOM(node) { 20 199 return [ ··· 22 201 { 23 202 class: 24 203 "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", 204 + "data-mention": "", 25 205 "data-handle": node.attrs.handle, 206 + "data-did": node.attrs.did || "", 26 207 }, 27 208 `@${node.attrs.handle}`, 28 209 ]; 29 210 }, 30 211 parseDOM: [ 31 212 { 32 - tag: "span.mention", 213 + tag: "span[data-mention]", 33 214 getAttrs(dom) { 34 215 if (typeof dom === "string") return false; 35 - return { handle: dom.getAttribute("data-handle") ?? "" }; 216 + return { 217 + handle: dom.getAttribute("data-handle") ?? "", 218 + did: dom.getAttribute("data-did") || null, 219 + }; 36 220 }, 37 221 }, 38 222 ], 39 - }), 40 - marks: markdownSchema.spec.marks, 41 - }); 223 + }, 224 + }; 225 + 226 + const marks: Record<string, MarkSpec> = { 227 + em: { 228 + parseDOM: [ 229 + { tag: "i" }, 230 + { tag: "em" }, 231 + { style: "font-style=italic" }, 232 + { style: "font-style=normal", clearMark: (m) => m.type.name === "em" }, 233 + ], 234 + toDOM() { 235 + return ["em", { class: "italic" }]; 236 + }, 237 + }, 238 + 239 + strong: { 240 + parseDOM: [ 241 + { tag: "strong" }, 242 + { 243 + tag: "b", 244 + getAttrs: (node) => node.style.fontWeight !== "normal" && null, 245 + }, 246 + { style: "font-weight=400", clearMark: (m) => m.type.name === "strong" }, 247 + { 248 + style: "font-weight", 249 + getAttrs: (value) => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null, 250 + }, 251 + ], 252 + toDOM() { 253 + return ["strong", { class: "font-bold" }]; 254 + }, 255 + }, 256 + 257 + link: { 258 + attrs: { 259 + href: {}, 260 + title: { default: null }, 261 + }, 262 + inclusive: false, 263 + parseDOM: [ 264 + { 265 + tag: "a[href]", 266 + getAttrs(dom) { 267 + if (typeof dom === "string") return false; 268 + return { 269 + href: dom.getAttribute("href"), 270 + title: dom.getAttribute("title"), 271 + }; 272 + }, 273 + }, 274 + ], 275 + toDOM(node) { 276 + return [ 277 + "a", 278 + { 279 + href: node.attrs.href, 280 + title: node.attrs.title, 281 + class: "text-blue-600 dark:text-blue-400 hover:underline", 282 + target: "_blank", 283 + rel: "noopener noreferrer", 284 + }, 285 + ]; 286 + }, 287 + }, 288 + 289 + code: { 290 + parseDOM: [{ tag: "code" }], 291 + toDOM() { 292 + return [ 293 + "code", 294 + { 295 + class: 296 + "bg-gray-100 dark:bg-slate-700 text-gray-800 dark:text-gray-200 px-1 py-0.5 rounded font-mono text-sm", 297 + }, 298 + ]; 299 + }, 300 + }, 301 + }; 302 + 303 + export const schema = new Schema({ nodes, marks });