a tool for shared writing and social publishing
at feature/at-mentions 202 lines 6.4 kB view raw
1import { Doc, applyUpdate, XmlElement, XmlHook, XmlText } from "yjs"; 2import { nodes, marks } from "prosemirror-schema-basic"; 3import { CSSProperties, Fragment } from "react"; 4import { theme } from "tailwind.config"; 5import * as base64 from "base64-js"; 6import { didToBlueskyUrl } from "src/utils/mentionUtils"; 7import { AtMentionLink } from "components/AtMentionLink"; 8 9type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 10export function RenderYJSFragment({ 11 value, 12 wrapper, 13 attrs, 14}: { 15 value: string; 16 wrapper: BlockElements; 17 attrs?: { [k: string]: any }; 18}) { 19 if (!value) 20 return <BlockWrapper wrapper={wrapper} attrs={attrs}></BlockWrapper>; 21 let doc = new Doc(); 22 const update = base64.toByteArray(value); 23 applyUpdate(doc, update); 24 let [node] = doc.getXmlElement("prosemirror").toArray(); 25 if (node.constructor === XmlElement) { 26 switch (node.nodeName as keyof typeof nodes) { 27 case "paragraph": { 28 let children = node.toArray(); 29 return ( 30 <BlockWrapper wrapper={wrapper} attrs={attrs}> 31 {children.length === 0 ? ( 32 <br /> 33 ) : ( 34 node.toArray().map((node, index) => { 35 if (node.constructor === XmlText) { 36 let deltas = node.toDelta() as Delta[]; 37 if (deltas.length === 0) return <br key={index} />; 38 return ( 39 <Fragment key={index}> 40 {deltas.map((d, index) => { 41 if (d.attributes?.link) 42 return ( 43 <a 44 href={d.attributes.link.href} 45 key={index} 46 {...attributesToStyle(d)} 47 > 48 {d.insert} 49 </a> 50 ); 51 return ( 52 <span 53 key={index} 54 {...attributesToStyle(d)} 55 {...attrs} 56 > 57 {d.insert} 58 </span> 59 ); 60 })} 61 </Fragment> 62 ); 63 } 64 65 if (node.constructor === XmlElement && node.nodeName === "hard_break") { 66 return <br key={index} />; 67 } 68 69 // Handle didMention inline nodes 70 if (node.constructor === XmlElement && node.nodeName === "didMention") { 71 const did = node.getAttribute("did") || ""; 72 const text = node.getAttribute("text") || ""; 73 return ( 74 <a 75 href={didToBlueskyUrl(did)} 76 target="_blank" 77 rel="noopener noreferrer" 78 key={index} 79 className="text-accent-contrast hover:underline cursor-pointer" 80 > 81 {text} 82 </a> 83 ); 84 } 85 86 // Handle atMention inline nodes 87 if (node.constructor === XmlElement && node.nodeName === "atMention") { 88 const atURI = node.getAttribute("atURI") || ""; 89 const text = node.getAttribute("text") || ""; 90 return ( 91 <AtMentionLink key={index} atURI={atURI}> 92 {text} 93 </AtMentionLink> 94 ); 95 } 96 97 return null; 98 }) 99 )} 100 </BlockWrapper> 101 ); 102 } 103 case "hard_break": 104 return <div />; 105 default: 106 return null; 107 } 108 } 109 return <br />; 110} 111 112const BlockWrapper = (props: { 113 wrapper: BlockElements; 114 children?: React.ReactNode; 115 attrs?: { [k: string]: any }; 116}) => { 117 if (props.wrapper === null && props.children === null) return <br />; 118 if (props.wrapper === null) return <>{props.children}</>; 119 switch (props.wrapper) { 120 case "p": 121 return <p {...props.attrs}>{props.children}</p>; 122 case "blockquote": 123 return <blockquote {...props.attrs}>{props.children}</blockquote>; 124 125 case "h1": 126 return <h1 {...props.attrs}>{props.children}</h1>; 127 case "h2": 128 return <h2 {...props.attrs}>{props.children}</h2>; 129 case "h3": 130 return <h3 {...props.attrs}>{props.children}</h3>; 131 } 132}; 133 134export type Delta = { 135 insert: string; 136 attributes?: { 137 strong?: {}; 138 code?: {}; 139 em?: {}; 140 underline?: {}; 141 strikethrough?: {}; 142 highlight?: { color: string }; 143 link?: { href: string }; 144 }; 145}; 146 147function attributesToStyle(d: Delta) { 148 let props = { 149 style: {}, 150 className: "", 151 } as { style: CSSProperties; className: string } & { 152 [s: `data-${string}`]: any; 153 }; 154 155 if (d.attributes?.code) props.className += " inline-code"; 156 if (d.attributes?.strong) props.style.fontWeight = "700"; 157 if (d.attributes?.em) props.style.fontStyle = "italic"; 158 if (d.attributes?.underline) props.style.textDecoration = "underline"; 159 if (d.attributes?.strikethrough) { 160 (props.style.textDecoration = "line-through"), 161 (props.style.textDecorationColor = theme.colors.tertiary); 162 } 163 if (d.attributes?.highlight) { 164 props.className += " highlight"; 165 props["data-color"] = d.attributes.highlight.color; 166 props.style.backgroundColor = 167 d.attributes?.highlight.color === "1" 168 ? theme.colors["highlight-1"] 169 : d.attributes.highlight.color === "2" 170 ? theme.colors["highlight-2"] 171 : theme.colors["highlight-3"]; 172 } 173 174 return props; 175} 176 177export function YJSFragmentToString( 178 node: XmlElement | XmlText | XmlHook, 179): string { 180 if (node.constructor === XmlElement) { 181 // Handle hard_break nodes specially 182 if (node.nodeName === "hard_break") { 183 return "\n"; 184 } 185 // Handle inline mention nodes 186 if (node.nodeName === "didMention" || node.nodeName === "atMention") { 187 return node.getAttribute("text") || ""; 188 } 189 return node 190 .toArray() 191 .map((f) => YJSFragmentToString(f)) 192 .join(""); 193 } 194 if (node.constructor === XmlText) { 195 return (node.toDelta() as Delta[]) 196 .map((d) => { 197 return d.insert; 198 }) 199 .join(""); 200 } 201 return ""; 202}