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 quotes have a page component
awarm.space
5 months ago
56610a67
7a4427e1
+96
-28
7 changed files
expand all
collapse all
unified
split
app
lish
[did]
[publication]
[rkey]
PostContent.tsx
PostPages.tsx
QuoteHandler.tsx
TextBlock.tsx
quotePosition.ts
useHighlight.tsx
components
Blocks
ImageBlock.tsx
+37
-6
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
···
50
50
}) {
51
51
return (
52
52
<div
53
53
-
id="post-content"
53
53
+
//The postContent class is important for QuoteHandler
54
54
className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-2 ${className}`}
55
55
>
56
56
{blocks.map((b, index) => {
···
103
103
scrollMarginBottom: "4rem",
104
104
wordBreak: "break-word" as React.CSSProperties["wordBreak"],
105
105
},
106
106
-
id: preview ? undefined : index.join("."),
106
106
+
id: preview
107
107
+
? undefined
108
108
+
: pageId
109
109
+
? `${pageId}~${index.join(".")}`
110
110
+
: index.join("."),
107
111
"data-index": index.join("."),
112
112
+
"data-page-id": pageId,
108
113
};
109
114
let alignment =
110
115
b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignRight"
···
175
180
did={did}
176
181
key={i}
177
182
className={className}
183
183
+
pageId={pageId}
178
184
/>
179
185
))}
180
186
</ul>
···
277
283
plaintext={b.block.plaintext}
278
284
index={index}
279
285
preview={preview}
286
286
+
pageId={pageId}
280
287
/>
281
288
</blockquote>
282
289
);
···
289
296
plaintext={b.block.plaintext}
290
297
index={index}
291
298
preview={preview}
299
299
+
pageId={pageId}
292
300
/>
293
301
</p>
294
302
);
···
296
304
if (b.block.level === 1)
297
305
return (
298
306
<h2 className={`${className}`} {...blockProps}>
299
299
-
<TextBlock {...b.block} index={index} preview={preview} />
307
307
+
<TextBlock
308
308
+
{...b.block}
309
309
+
index={index}
310
310
+
preview={preview}
311
311
+
pageId={pageId}
312
312
+
/>
300
313
</h2>
301
314
);
302
315
if (b.block.level === 2)
303
316
return (
304
317
<h3 className={`${className}`} {...blockProps}>
305
305
-
<TextBlock {...b.block} index={index} preview={preview} />
318
318
+
<TextBlock
319
319
+
{...b.block}
320
320
+
index={index}
321
321
+
preview={preview}
322
322
+
pageId={pageId}
323
323
+
/>
306
324
</h3>
307
325
);
308
326
if (b.block.level === 3)
309
327
return (
310
328
<h4 className={`${className}`} {...blockProps}>
311
311
-
<TextBlock {...b.block} index={index} preview={preview} />
329
329
+
<TextBlock
330
330
+
{...b.block}
331
331
+
index={index}
332
332
+
preview={preview}
333
333
+
pageId={pageId}
334
334
+
/>
312
335
</h4>
313
336
);
314
337
// if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>;
315
338
// if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>;
316
339
return (
317
340
<h6 className={`${className}`} {...blockProps}>
318
318
-
<TextBlock {...b.block} index={index} preview={preview} />
341
341
+
<TextBlock
342
342
+
{...b.block}
343
343
+
index={index}
344
344
+
preview={preview}
345
345
+
pageId={pageId}
346
346
+
/>
319
347
</h6>
320
348
);
321
349
}
···
331
359
did: string;
332
360
className?: string;
333
361
bskyPostData: AppBskyFeedDefs.PostView[];
362
362
+
pageId?: string;
334
363
}) {
335
364
let children = props.item.children?.length ? (
336
365
<ul className="-ml-[7px] sm:ml-[7px]">
···
343
372
did={props.did}
344
373
key={index}
345
374
className={props.className}
375
375
+
pageId={props.pageId}
346
376
/>
347
377
))}
348
378
</ul>
···
361
391
did={props.did}
362
392
isList
363
393
index={props.index}
394
394
+
pageId={props.pageId}
364
395
/>
365
396
{children}{" "}
366
397
</div>
+1
-4
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
···
58
58
export function PostPages({
59
59
document,
60
60
blocks,
61
61
-
name,
62
61
did,
63
62
profile,
64
63
preferences,
···
70
69
document_uri: string;
71
70
document: PostPageData;
72
71
blocks: PubLeafletPagesLinearDocument.Block[];
73
73
-
name: string;
74
72
profile: ProfileViewDetailed;
75
73
pubRecord: PubLeafletPublication.Record;
76
74
did: string;
···
99
97
<PostHeader
100
98
data={document}
101
99
profile={profile}
102
102
-
name={name}
103
100
preferences={preferences}
104
101
/>
105
102
<PostContent
···
137
134
document.documents_in_publications[0].publications
138
135
.publication_subscriptions
139
136
}
140
140
-
pubName={name}
137
137
+
pubName={document.documents_in_publications[0].publications.name}
141
138
/>
142
139
)}
143
140
</div>
+19
-8
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
···
24
24
useEffect(() => {
25
25
const handleSelectionChange = (e: Event) => {
26
26
const selection = document.getSelection();
27
27
-
const postContent = document.getElementById("post-content");
27
27
+
28
28
+
// Check if selection is within any element with postContent class
28
29
const isWithinPostContent =
29
29
-
postContent && selection?.rangeCount && selection.rangeCount > 0
30
30
-
? postContent.contains(
31
31
-
selection.getRangeAt(0).commonAncestorContainer,
32
32
-
)
30
30
+
selection?.rangeCount && selection.rangeCount > 0
31
31
+
? (() => {
32
32
+
const range = selection.getRangeAt(0);
33
33
+
const ancestor = range.commonAncestorContainer;
34
34
+
const element = ancestor.nodeType === Node.ELEMENT_NODE
35
35
+
? ancestor as Element
36
36
+
: ancestor.parentElement;
37
37
+
return element?.closest('.postContent') !== null;
38
38
+
})()
33
39
: false;
34
40
35
41
if (!selection || !isWithinPostContent || !selection?.toString())
···
88
94
endIndex?.element,
89
95
);
90
96
let position: QuotePosition = {
97
97
+
...(startIndex.pageId && { pageId: startIndex.pageId }),
91
98
start: {
92
99
block: startIndex?.index.split(".").map((i) => parseInt(i)),
93
100
offset: startOffset,
···
145
152
// Clear existing query parameters
146
153
currentUrl.search = "";
147
154
148
148
-
currentUrl.hash = `#${pos?.start.block.join(".")}_${pos?.start.offset}`;
155
155
+
const fragmentId = pos?.pageId
156
156
+
? `${pos.pageId}~${pos.start.block.join(".")}_${pos.start.offset}`
157
157
+
: `${pos?.start.block.join(".")}_${pos?.start.offset}`;
158
158
+
currentUrl.hash = `#${fragmentId}`;
149
159
return [currentUrl.toString(), pos];
150
160
}, [props.position]);
151
161
let pubRecord = data.documents_in_publications[0]?.publications?.record as
···
210
220
);
211
221
};
212
222
213
213
-
function findDataIndex(node: Node): { index: string; element: Element } | null {
223
223
+
function findDataIndex(node: Node): { index: string; element: Element; pageId?: string } | null {
214
224
if (node.nodeType === Node.ELEMENT_NODE) {
215
225
const element = node as Element;
216
226
if (element.hasAttribute("data-index")) {
217
227
const index = element.getAttribute("data-index");
218
228
if (index) {
219
219
-
return { index, element };
229
229
+
const pageId = element.getAttribute("data-page-id") || undefined;
230
230
+
return { index, element, pageId };
220
231
}
221
232
}
222
233
}
+7
-3
app/lish/[did]/[publication]/[rkey]/TextBlock.tsx
···
11
11
facets?: Facet[];
12
12
index: number[];
13
13
preview?: boolean;
14
14
+
pageId?: string;
14
15
}) {
15
16
let children = [];
16
16
-
let highlights = useHighlight(props.index);
17
17
+
let highlights = useHighlight(props.index, props.pageId);
17
18
let facets = useMemo(() => {
18
19
if (props.preview) return props.facets;
19
20
let facets = [...(props.facets || [])];
20
21
for (let highlight of highlights) {
22
22
+
const fragmentId = props.pageId
23
23
+
? `${props.pageId}~${props.index.join(".")}_${highlight.startOffset || 0}`
24
24
+
: `${props.index.join(".")}_${highlight.startOffset || 0}`;
21
25
facets = addFacet(
22
26
facets,
23
27
{
···
35
39
{ $type: "pub.leaflet.richtext.facet#highlight" },
36
40
{
37
41
$type: "pub.leaflet.richtext.facet#id",
38
38
-
id: `${props.index.join(".")}_${highlight.startOffset || 0}`,
42
42
+
id: fragmentId,
39
43
},
40
44
],
41
45
},
···
43
47
);
44
48
}
45
49
return facets;
46
46
-
}, [props.plaintext, props.facets, highlights, props.preview]);
50
50
+
}, [props.plaintext, props.facets, highlights, props.preview, props.pageId]);
47
51
return <BaseTextBlock {...props} facets={facets} />;
48
52
}
49
53
+20
-5
app/lish/[did]/[publication]/[rkey]/quotePosition.ts
···
1
1
export interface QuotePosition {
2
2
+
pageId?: string;
2
3
start: {
3
4
block: number[];
4
5
offset: number;
···
14
15
/**
15
16
* Encodes quote position into a URL-friendly string
16
17
* Format: startBlock_startOffset-endBlock_endOffset
18
18
+
* Format with page: pageId~startBlock_startOffset-endBlock_endOffset
17
19
* Block paths are joined with dots: 1.2.0_45-1.2.3_67
18
18
-
* Simple blocks: 0:12-2:45
20
20
+
* Simple blocks: 0_12-2_45
21
21
+
* With page: page1~0_12-2_45
19
22
*/
20
23
export function encodeQuotePosition(position: QuotePosition): string {
21
21
-
const { start, end } = position;
22
22
-
return `${start.block.join(".")}_${start.offset}-${end.block.join(".")}_${end.offset}`;
24
24
+
const { pageId, start, end } = position;
25
25
+
const positionStr = `${start.block.join(".")}_${start.offset}-${end.block.join(".")}_${end.offset}`;
26
26
+
return pageId ? `${pageId}~${positionStr}` : positionStr;
23
27
}
24
28
25
29
/**
···
28
32
*/
29
33
export function decodeQuotePosition(encoded: string): QuotePosition | null {
30
34
try {
31
31
-
// Match format: blockPath:number-blockPath:number
35
35
+
// Check for pageId prefix (format: pageId~blockPath_number-blockPath_number)
36
36
+
let pageId: string | undefined;
37
37
+
let positionStr = encoded;
38
38
+
39
39
+
const tildeIndex = encoded.indexOf("~");
40
40
+
if (tildeIndex !== -1) {
41
41
+
pageId = encoded.substring(0, tildeIndex);
42
42
+
positionStr = encoded.substring(tildeIndex + 1);
43
43
+
}
44
44
+
45
45
+
// Match format: blockPath_number-blockPath_number
32
46
// Block paths can be: 5, 1.2, 0.1.3, etc.
33
33
-
const match = encoded.match(/^([\d.]+)_(\d+)-([\d.]+)_(\d+)$/);
47
47
+
const match = positionStr.match(/^([\d.]+)_(\d+)-([\d.]+)_(\d+)$/);
34
48
35
49
if (!match) {
36
50
return null;
···
39
53
const [, startBlockPath, startOffset, endBlockPath, endOffset] = match;
40
54
41
55
const position: QuotePosition = {
56
56
+
...(pageId && { pageId }),
42
57
start: {
43
58
block: startBlockPath.split(".").map((i) => parseInt(i)),
44
59
offset: parseInt(startOffset, 10),
+9
-1
app/lish/[did]/[publication]/[rkey]/useHighlight.tsx
···
11
11
activeHighlight: null as null | QuotePosition,
12
12
}));
13
13
14
14
-
export const useHighlight = (pos: number[]) => {
14
14
+
export const useHighlight = (pos: number[], pageId?: string) => {
15
15
let doc = useContext(PostPageContext);
16
16
let { quote } = useParams();
17
17
let activeHighlight = useActiveHighlightState(
···
23
23
return highlights
24
24
.map((quotePosition) => {
25
25
if (!quotePosition) return null;
26
26
+
// Filter by pageId if provided
27
27
+
if (pageId && quotePosition.pageId !== pageId) {
28
28
+
return null;
29
29
+
}
30
30
+
// If highlight has pageId but block doesn't, skip
31
31
+
if (quotePosition.pageId && !pageId) {
32
32
+
return null;
33
33
+
}
26
34
let maxLength = Math.max(
27
35
quotePosition.start.block.length,
28
36
quotePosition.end.block.length,
+3
-1
components/Blocks/ImageBlock.tsx
···
140
140
) : (
141
141
<Image
142
142
alt={altText || ""}
143
143
-
src={new URL(image.data.src).pathname.split("/").slice(5).join("/")}
143
143
+
src={
144
144
+
"/" + new URL(image.data.src).pathname.split("/").slice(5).join("/")
145
145
+
}
144
146
height={image?.data.height}
145
147
width={image?.data.width}
146
148
className={className}