a tool for shared writing and social publishing
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}