tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
289
fork
atom
a tool for shared writing and social publishing
289
fork
atom
overview
issues
28
pulls
pipelines
make mentions inline nodes
awarm.space
3 months ago
5a753cd6
746bddfd
+165
-99
5 changed files
expand all
collapse all
unified
split
actions
publishToPublication.ts
app
[leaflet_id]
publish
BskyPostEditorProsemirror.tsx
components
Blocks
TextBlock
RenderYJSFragment.tsx
mountProsemirror.ts
schema.ts
+60
-19
actions/publishToPublication.ts
···
349
349
Y.applyUpdate(doc, update);
350
350
let nodes = doc.getXmlElement("prosemirror").toArray();
351
351
let stringValue = YJSFragmentToString(nodes[0]);
352
352
-
let facets = YJSFragmentToFacets(nodes[0]);
352
352
+
let { facets } = YJSFragmentToFacets(nodes[0]);
353
353
return [stringValue, facets] as const;
354
354
};
355
355
if (b.type === "card") {
···
610
610
611
611
function YJSFragmentToFacets(
612
612
node: Y.XmlElement | Y.XmlText | Y.XmlHook,
613
613
-
): PubLeafletRichtextFacet.Main[] {
613
613
+
byteOffset: number = 0,
614
614
+
): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } {
614
615
if (node.constructor === Y.XmlElement) {
615
615
-
return node
616
616
-
.toArray()
617
617
-
.map((f) => YJSFragmentToFacets(f))
618
618
-
.flat();
616
616
+
// Handle inline mention nodes
617
617
+
if (node.nodeName === "didMention") {
618
618
+
const text = node.getAttribute("text") || "";
619
619
+
const unicodestring = new UnicodeString(text);
620
620
+
const facet: PubLeafletRichtextFacet.Main = {
621
621
+
index: {
622
622
+
byteStart: byteOffset,
623
623
+
byteEnd: byteOffset + unicodestring.length,
624
624
+
},
625
625
+
features: [
626
626
+
{
627
627
+
$type: "pub.leaflet.richtext.facet#didMention",
628
628
+
did: node.getAttribute("did"),
629
629
+
},
630
630
+
],
631
631
+
};
632
632
+
return { facets: [facet], byteLength: unicodestring.length };
633
633
+
}
634
634
+
635
635
+
if (node.nodeName === "atMention") {
636
636
+
const text = node.getAttribute("text") || "";
637
637
+
const unicodestring = new UnicodeString(text);
638
638
+
const facet: PubLeafletRichtextFacet.Main = {
639
639
+
index: {
640
640
+
byteStart: byteOffset,
641
641
+
byteEnd: byteOffset + unicodestring.length,
642
642
+
},
643
643
+
features: [
644
644
+
{
645
645
+
$type: "pub.leaflet.richtext.facet#atMention",
646
646
+
atURI: node.getAttribute("atURI"),
647
647
+
},
648
648
+
],
649
649
+
};
650
650
+
return { facets: [facet], byteLength: unicodestring.length };
651
651
+
}
652
652
+
653
653
+
if (node.nodeName === "hard_break") {
654
654
+
const unicodestring = new UnicodeString("\n");
655
655
+
return { facets: [], byteLength: unicodestring.length };
656
656
+
}
657
657
+
658
658
+
// For other elements (like paragraph), process children
659
659
+
let allFacets: PubLeafletRichtextFacet.Main[] = [];
660
660
+
let currentOffset = byteOffset;
661
661
+
for (const child of node.toArray()) {
662
662
+
const result = YJSFragmentToFacets(child, currentOffset);
663
663
+
allFacets.push(...result.facets);
664
664
+
currentOffset += result.byteLength;
665
665
+
}
666
666
+
return { facets: allFacets, byteLength: currentOffset - byteOffset };
619
667
}
668
668
+
620
669
if (node.constructor === Y.XmlText) {
621
670
let facets: PubLeafletRichtextFacet.Main[] = [];
622
671
let delta = node.toDelta() as Delta[];
623
623
-
let byteStart = 0;
672
672
+
let byteStart = byteOffset;
673
673
+
let totalLength = 0;
624
674
for (let d of delta) {
625
675
let unicodestring = new UnicodeString(d.insert);
626
676
let facet: PubLeafletRichtextFacet.Main = {
···
636
686
$type: "pub.leaflet.richtext.facet#strikethrough",
637
687
});
638
688
639
639
-
if (d.attributes?.didMention)
640
640
-
facet.features.push({
641
641
-
$type: "pub.leaflet.richtext.facet#didMention",
642
642
-
did: d.attributes.didMention.did,
643
643
-
});
644
644
-
if (d.attributes?.atMention)
645
645
-
facet.features.push({
646
646
-
$type: "pub.leaflet.richtext.facet#atMention",
647
647
-
atURI: d.attributes.atMention.atURI,
648
648
-
});
649
689
if (d.attributes?.code)
650
690
facet.features.push({ $type: "pub.leaflet.richtext.facet#code" });
651
691
if (d.attributes?.highlight)
···
663
703
});
664
704
if (facet.features.length > 0) facets.push(facet);
665
705
byteStart += unicodestring.length;
706
706
+
totalLength += unicodestring.length;
666
707
}
667
667
-
return facets;
708
708
+
return { facets, byteLength: totalLength };
668
709
}
669
669
-
return [];
710
710
+
return { facets: [], byteLength: 0 };
670
711
}
671
712
672
713
type ExcludeString<T> = T extends string
+19
-16
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
···
384
384
const tr = view.state.tr;
385
385
386
386
if (mention.type == "did") {
387
387
-
// Delete the query text (keep the @)
388
388
-
tr.delete(from + 1, to);
389
389
-
tr.insertText(mention.handle, from + 1);
390
390
-
tr.addMark(
391
391
-
from,
392
392
-
from + 1 + mention.handle.length,
393
393
-
schema.marks.didMention.create({ did: mention.did }),
394
394
-
);
395
395
-
tr.insertText(" ", from + 1 + mention.handle.length);
387
387
+
// Delete the @ and any query text
388
388
+
tr.delete(from, to);
389
389
+
// Insert didMention inline node
390
390
+
const mentionText = "@" + mention.handle;
391
391
+
const didMentionNode = schema.nodes.didMention.create({
392
392
+
did: mention.did,
393
393
+
text: mentionText,
394
394
+
});
395
395
+
tr.insert(from, didMentionNode);
396
396
+
// Add a space after the mention
397
397
+
tr.insertText(" ", from + 1);
396
398
}
397
399
if (mention.type === "publication" || mention.type === "post") {
398
400
// Delete the @ and any query text
399
401
tr.delete(from, to);
400
402
let name = mention.type == "post" ? mention.title : mention.name;
401
401
-
tr.insertText(name, from);
402
402
-
tr.addMark(
403
403
-
from,
404
404
-
from + name.length,
405
405
-
schema.marks.atMention.create({ atURI: mention.uri }),
406
406
-
);
407
407
-
tr.insertText(" ", from + name.length);
403
403
+
// Insert atMention inline node
404
404
+
const atMentionNode = schema.nodes.atMention.create({
405
405
+
atURI: mention.uri,
406
406
+
text: name,
407
407
+
});
408
408
+
tr.insert(from, atMentionNode);
409
409
+
// Add a space after the mention
410
410
+
tr.insertText(" ", from + 1);
408
411
}
409
412
410
413
view.dispatch(tr);
+32
-26
components/Blocks/TextBlock/RenderYJSFragment.tsx
···
48
48
{d.insert}
49
49
</a>
50
50
);
51
51
-
if (d.attributes?.didMention)
52
52
-
return (
53
53
-
<a
54
54
-
href={didToBlueskyUrl(d.attributes.didMention.did)}
55
55
-
target="_blank"
56
56
-
rel="noopener noreferrer"
57
57
-
key={index}
58
58
-
{...attributesToStyle(d)}
59
59
-
className={`${attributesToStyle(d).className} text-accent-contrast hover:underline cursor-pointer`}
60
60
-
>
61
61
-
{d.insert}
62
62
-
</a>
63
63
-
);
64
64
-
if (d.attributes?.atMention) {
65
65
-
return (
66
66
-
<AtMentionLink
67
67
-
key={index}
68
68
-
atURI={d.attributes.atMention.atURI}
69
69
-
className={attributesToStyle(d).className}
70
70
-
>
71
71
-
{d.insert}
72
72
-
</AtMentionLink>
73
73
-
);
74
74
-
}
75
51
return (
76
52
<span
77
53
key={index}
···
90
66
return <br key={index} />;
91
67
}
92
68
69
69
+
// Handle didMention inline nodes
70
70
+
if (node.constructor === XmlElement && node.nodeName === "didMention") {
71
71
+
const did = node.getAttribute("did") || "";
72
72
+
const text = node.getAttribute("text") || "";
73
73
+
return (
74
74
+
<a
75
75
+
href={didToBlueskyUrl(did)}
76
76
+
target="_blank"
77
77
+
rel="noopener noreferrer"
78
78
+
key={index}
79
79
+
className="text-accent-contrast hover:underline cursor-pointer"
80
80
+
>
81
81
+
{text}
82
82
+
</a>
83
83
+
);
84
84
+
}
85
85
+
86
86
+
// Handle atMention inline nodes
87
87
+
if (node.constructor === XmlElement && node.nodeName === "atMention") {
88
88
+
const atURI = node.getAttribute("atURI") || "";
89
89
+
const text = node.getAttribute("text") || "";
90
90
+
return (
91
91
+
<AtMentionLink key={index} atURI={atURI}>
92
92
+
{text}
93
93
+
</AtMentionLink>
94
94
+
);
95
95
+
}
96
96
+
93
97
return null;
94
98
})
95
99
)}
···
133
137
strong?: {};
134
138
code?: {};
135
139
em?: {};
136
136
-
didMention?: { did: string };
137
137
-
atMention?: { atURI: string };
138
140
underline?: {};
139
141
strikethrough?: {};
140
142
highlight?: { color: string };
···
179
181
// Handle hard_break nodes specially
180
182
if (node.nodeName === "hard_break") {
181
183
return "\n";
184
184
+
}
185
185
+
// Handle inline mention nodes
186
186
+
if (node.nodeName === "didMention" || node.nodeName === "atMention") {
187
187
+
return node.getAttribute("text") || "";
182
188
}
183
189
return node
184
190
.toArray()
+15
-10
components/Blocks/TextBlock/mountProsemirror.ts
···
94
94
return;
95
95
}
96
96
97
97
-
// Check for didMention marks
98
98
-
let didMentionMark = nodeAt1?.marks.find((f) => f.type === schema.marks.didMention) ||
99
99
-
nodeAt2?.marks.find((f) => f.type === schema.marks.didMention);
100
100
-
if (didMentionMark) {
101
101
-
window.open(didToBlueskyUrl(didMentionMark.attrs.did), "_blank", "noopener,noreferrer");
97
97
+
// Check for didMention inline nodes
98
98
+
if (nodeAt1?.type === schema.nodes.didMention) {
99
99
+
window.open(didToBlueskyUrl(nodeAt1.attrs.did), "_blank", "noopener,noreferrer");
100
100
+
return;
101
101
+
}
102
102
+
if (nodeAt2?.type === schema.nodes.didMention) {
103
103
+
window.open(didToBlueskyUrl(nodeAt2.attrs.did), "_blank", "noopener,noreferrer");
102
104
return;
103
105
}
104
106
105
105
-
// Check for atMention marks
106
106
-
let atMentionMark = nodeAt1?.marks.find((f) => f.type === schema.marks.atMention) ||
107
107
-
nodeAt2?.marks.find((f) => f.type === schema.marks.atMention);
108
108
-
if (atMentionMark) {
109
109
-
const url = atUriToUrl(atMentionMark.attrs.atURI);
107
107
+
// Check for atMention inline nodes
108
108
+
if (nodeAt1?.type === schema.nodes.atMention) {
109
109
+
const url = atUriToUrl(nodeAt1.attrs.atURI);
110
110
+
window.open(url, "_blank", "noopener,noreferrer");
111
111
+
return;
112
112
+
}
113
113
+
if (nodeAt2?.type === schema.nodes.atMention) {
114
114
+
const url = atUriToUrl(nodeAt2.attrs.atURI);
110
115
window.open(url, "_blank", "noopener,noreferrer");
111
116
return;
112
117
}
+39
-28
components/Blocks/TextBlock/schema.ts
···
1
1
import { AtUri } from "@atproto/api";
2
2
-
import { Schema, Node, MarkSpec } from "prosemirror-model";
2
2
+
import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model";
3
3
import { marks } from "prosemirror-schema-basic";
4
4
import { theme } from "tailwind.config";
5
5
···
104
104
return ["a", { href, target: "_blank" }, 0];
105
105
},
106
106
} as MarkSpec,
107
107
+
},
108
108
+
nodes: {
109
109
+
doc: { content: "block" },
110
110
+
paragraph: {
111
111
+
content: "inline*",
112
112
+
group: "block",
113
113
+
parseDOM: [{ tag: "p" }],
114
114
+
toDOM: () => ["p", 0] as const,
115
115
+
},
116
116
+
text: {
117
117
+
group: "inline",
118
118
+
},
119
119
+
hard_break: {
120
120
+
group: "inline",
121
121
+
inline: true,
122
122
+
selectable: false,
123
123
+
parseDOM: [{ tag: "br" }],
124
124
+
toDOM: () => ["br"] as const,
125
125
+
},
107
126
atMention: {
108
127
attrs: {
109
128
atURI: {},
129
129
+
text: { default: "" },
110
130
},
111
111
-
inclusive: false,
131
131
+
group: "inline",
132
132
+
inline: true,
133
133
+
atom: true,
134
134
+
selectable: true,
135
135
+
draggable: true,
112
136
parseDOM: [
113
137
{
114
138
tag: "span.atMention",
115
139
getAttrs(dom: HTMLElement) {
116
140
return {
117
141
atURI: dom.getAttribute("data-at-uri"),
142
142
+
text: dom.textContent || "",
118
143
};
119
144
},
120
145
},
···
122
147
toDOM(node) {
123
148
// NOTE: This rendering should match the AtMentionLink component in
124
149
// components/AtMentionLink.tsx. If you update one, update the other.
125
125
-
// We can't use the React component here because ProseMirror expects DOM specs.
126
150
let className = "atMention text-accent-contrast";
127
151
let aturi = new AtUri(node.attrs.atURI);
128
152
if (aturi.collection === "pub.leaflet.publication")
···
151
175
loading: "lazy",
152
176
},
153
177
],
154
154
-
["span", 0],
178
178
+
node.attrs.text,
155
179
];
156
180
}
157
181
···
161
185
class: className,
162
186
"data-at-uri": node.attrs.atURI,
163
187
},
164
164
-
0,
188
188
+
node.attrs.text,
165
189
];
166
190
},
167
167
-
} as MarkSpec,
191
191
+
} as NodeSpec,
168
192
didMention: {
169
193
attrs: {
170
194
did: {},
195
195
+
text: { default: "" },
171
196
},
172
172
-
inclusive: false,
197
197
+
group: "inline",
198
198
+
inline: true,
199
199
+
atom: true,
200
200
+
selectable: true,
201
201
+
draggable: true,
173
202
parseDOM: [
174
203
{
175
204
tag: "span.didMention",
176
205
getAttrs(dom: HTMLElement) {
177
206
return {
178
207
did: dom.getAttribute("data-did"),
208
208
+
text: dom.textContent || "",
179
209
};
180
210
},
181
211
},
···
187
217
class: "didMention text-accent-contrast",
188
218
"data-did": node.attrs.did,
189
219
},
190
190
-
0,
220
220
+
node.attrs.text,
191
221
];
192
222
},
193
193
-
} as MarkSpec,
194
194
-
},
195
195
-
nodes: {
196
196
-
doc: { content: "block" },
197
197
-
paragraph: {
198
198
-
content: "inline*",
199
199
-
group: "block",
200
200
-
parseDOM: [{ tag: "p" }],
201
201
-
toDOM: () => ["p", 0] as const,
202
202
-
},
203
203
-
text: {
204
204
-
group: "inline",
205
205
-
},
206
206
-
hard_break: {
207
207
-
group: "inline",
208
208
-
inline: true,
209
209
-
selectable: false,
210
210
-
parseDOM: [{ tag: "br" }],
211
211
-
toDOM: () => ["br"] as const,
212
212
-
},
223
223
+
} as NodeSpec,
213
224
},
214
225
};
215
226
export const schema = new Schema(baseSchema);