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
29
pulls
pipelines
add mention on enter
awarm.space
4 months ago
731d8249
65ff2d66
+234
-89
8 changed files
expand all
collapse all
unified
split
app
[leaflet_id]
publish
BskyPostEditorProsemirror.tsx
api
rpc
[command]
route.ts
search_publication_names.ts
lish
[did]
[publication]
[rkey]
BaseTextBlock.tsx
components
Blocks
TextBlock
index.tsx
keymap.ts
mountProsemirror.ts
schema.ts
+121
-45
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
···
19
19
import { inputRules, InputRule } from "prosemirror-inputrules";
20
20
import { autolink } from "components/Blocks/TextBlock/autolink-plugin";
21
21
import { IOSBS } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox";
22
22
+
import { callRPC } from "app/api/rpc/client";
23
23
+
import { schema } from "components/Blocks/TextBlock/schema";
22
24
23
25
// Schema with only links, mentions, and hashtags marks
24
26
const bskyPostSchema = new Schema({
···
134
136
return tr;
135
137
});
136
138
}
137
137
-
139
139
+
export type MentionState = {
140
140
+
active: boolean;
141
141
+
range: { from: number; to: number } | null;
142
142
+
selectedMention: Mention | null;
143
143
+
};
138
144
export function BlueskyPostEditorProsemirror(props: {
139
139
-
editorStateRef: React.MutableRefObject<EditorState | null>;
145
145
+
editorStateRef: React.RefObject<EditorState | null>;
140
146
initialContent?: string;
141
147
onCharCountChange?: (count: number) => void;
142
148
}) {
143
149
const mountRef = useRef<HTMLDivElement | null>(null);
144
150
const viewRef = useRef<EditorView | null>(null);
145
151
const [editorState, setEditorState] = useState<EditorState | null>(null);
146
146
-
const [mentionState, setMentionState] = useState<{
147
147
-
active: boolean;
148
148
-
range: { from: number; to: number } | null;
149
149
-
selectedMention: { handle: string; did: string } | null;
150
150
-
}>({ active: false, range: null, selectedMention: null });
152
152
+
const [mentionState, setMentionState] = useState<MentionState>({
153
153
+
active: false,
154
154
+
range: null,
155
155
+
selectedMention: null,
156
156
+
});
151
157
152
158
const handleMentionSelect = useCallback(
153
153
-
(
154
154
-
mention: { handle: string; did: string },
155
155
-
range: { from: number; to: number },
156
156
-
) => {
159
159
+
(mention: Mention, range: { from: number; to: number }) => {
160
160
+
if (mention.type !== "did") return;
157
161
if (!viewRef.current) return;
158
162
const view = viewRef.current;
159
163
const { from, to } = range;
···
288
292
);
289
293
}
290
294
291
291
-
function MentionAutocomplete(props: {
295
295
+
export function MentionAutocomplete(props: {
292
296
editorState: EditorState;
293
297
view: React.RefObject<EditorView | null>;
294
294
-
onSelect: (
295
295
-
mention: { handle: string; did: string },
296
296
-
range: { from: number; to: number },
297
297
-
) => void;
298
298
+
onSelect: (mention: Mention, range: { from: number; to: number }) => void;
298
299
onMentionStateChange: (
299
300
active: boolean,
300
301
range: { from: number; to: number } | null,
301
301
-
selectedMention: { handle: string; did: string } | null,
302
302
+
selectedMention: Mention | null,
302
303
) => void;
303
304
}) {
304
305
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
···
325
326
);
326
327
327
328
// Look for @ followed by word characters before cursor
328
328
-
const match = textBefore.match(/@([\w.]*)$/);
329
329
+
const match = textBefore.match(/(?:^|\s)@([\w.]*)$/);
329
330
330
331
if (match && props.view.current) {
331
332
const queryBefore = match[1];
···
434
435
>
435
436
<ul className="list-none p-0 text-sm">
436
437
{suggestions.map((result, index) => {
437
437
-
return (
438
438
-
<div
439
439
-
className={`
438
438
+
if (result.type === "did")
439
439
+
return (
440
440
+
<div
441
441
+
className={`
440
442
MenuItem
441
443
font-bold z-10 py-1 px-3
442
444
text-left text-secondary
···
445
447
hover:bg-border-light hover:text-secondary
446
448
outline-none
447
449
`}
448
448
-
key={result.did}
449
449
-
onClick={() => {
450
450
-
if (mentionRange) {
451
451
-
props.onSelect(result, mentionRange);
452
452
-
setMentionQuery(null);
453
453
-
setMentionRange(null);
454
454
-
setMentionCoords(null);
455
455
-
}
456
456
-
}}
457
457
-
onMouseDown={(e) => e.preventDefault()}
458
458
-
>
459
459
-
@{result.handle}
460
460
-
</div>
461
461
-
);
450
450
+
key={result.did}
451
451
+
onClick={() => {
452
452
+
if (mentionRange) {
453
453
+
props.onSelect(result, mentionRange);
454
454
+
setMentionQuery(null);
455
455
+
setMentionRange(null);
456
456
+
setMentionCoords(null);
457
457
+
}
458
458
+
}}
459
459
+
onMouseDown={(e) => e.preventDefault()}
460
460
+
>
461
461
+
@{result.handle}
462
462
+
</div>
463
463
+
);
464
464
+
if (result.type == "publication") {
465
465
+
return (
466
466
+
<div
467
467
+
className={`
468
468
+
text-test
469
469
+
MenuItem
470
470
+
font-bold z-10 py-1 px-3
471
471
+
text-left
472
472
+
flex gap-2
473
473
+
${index === suggestionIndex ? "bg-border-light data-[highlighted]:text-secondary" : ""}
474
474
+
hover:bg-border-light hover:text-secondary
475
475
+
outline-none
476
476
+
`}
477
477
+
key={result.uri}
478
478
+
onClick={() => {
479
479
+
if (mentionRange) {
480
480
+
props.onSelect(result, mentionRange);
481
481
+
setMentionQuery(null);
482
482
+
setMentionRange(null);
483
483
+
setMentionCoords(null);
484
484
+
}
485
485
+
}}
486
486
+
onMouseDown={(e) => e.preventDefault()}
487
487
+
>
488
488
+
{result.name}
489
489
+
</div>
490
490
+
);
491
491
+
}
462
492
})}
463
493
</ul>
464
494
</Popover.Content>
···
467
497
);
468
498
}
469
499
500
500
+
export type Mention =
501
501
+
| { type: "did"; handle: string; did: string }
502
502
+
| { type: "publication"; uri: string; name: string };
470
503
function useMentionSuggestions(query: string | null) {
471
504
const [suggestionIndex, setSuggestionIndex] = useState(0);
472
472
-
const [suggestions, setSuggestions] = useState<
473
473
-
{ handle: string; did: string }[]
474
474
-
>([]);
505
505
+
const [suggestions, setSuggestions] = useState<Array<Mention>>([]);
475
506
476
507
useDebouncedEffect(
477
508
async () => {
···
481
512
}
482
513
483
514
const agent = new Agent("https://public.api.bsky.app");
484
484
-
const result = await agent.searchActorsTypeahead({
485
485
-
q: query,
486
486
-
limit: 8,
487
487
-
});
488
488
-
setSuggestions(
489
489
-
result.data.actors.map((actor) => ({
515
515
+
const [result, publications] = await Promise.all([
516
516
+
agent.searchActorsTypeahead({
517
517
+
q: query,
518
518
+
limit: 8,
519
519
+
}),
520
520
+
callRPC(`search_publication_names`, { query, limit: 8 }),
521
521
+
]);
522
522
+
setSuggestions([
523
523
+
...result.data.actors.map((actor) => ({
524
524
+
type: "did" as const,
490
525
handle: actor.handle,
491
526
did: actor.did,
492
527
})),
493
493
-
);
528
528
+
...publications.result.publications.map((p) => ({
529
529
+
type: "publication" as const,
530
530
+
uri: p.uri,
531
531
+
name: p.name,
532
532
+
})),
533
533
+
]);
494
534
},
495
535
300,
496
536
[query],
···
593
633
594
634
return features;
595
635
}
636
636
+
637
637
+
export const addMentionToEditor = (
638
638
+
mention: Mention,
639
639
+
range: { from: number; to: number },
640
640
+
view: EditorView,
641
641
+
) => {
642
642
+
if (!view) return;
643
643
+
const { from, to } = range;
644
644
+
const tr = view.state.tr;
645
645
+
// Delete the query text (keep the @)
646
646
+
tr.delete(from + 1, to);
647
647
+
648
648
+
if (mention.type == "did") {
649
649
+
tr.insertText(mention.handle, from + 1);
650
650
+
tr.addMark(
651
651
+
from,
652
652
+
from + 1 + mention.handle.length,
653
653
+
schema.marks.didMention.create({ did: mention.did }),
654
654
+
);
655
655
+
tr.insertText(" ", from + 1 + mention.handle.length);
656
656
+
}
657
657
+
if (mention.type === "publication") {
658
658
+
tr.insertText(mention.name, from + 1);
659
659
+
tr.addMark(
660
660
+
from,
661
661
+
from + 1 + mention.name.length,
662
662
+
schema.marks.atMention.create({ atURI: mention.uri }),
663
663
+
);
664
664
+
tr.insertText(" ", from + 1 + mention.name.length);
665
665
+
}
666
666
+
667
667
+
// Insert the mention text after the @
668
668
+
669
669
+
view.dispatch(tr);
670
670
+
view.focus();
671
671
+
};
+2
app/api/rpc/[command]/route.ts
···
11
11
} from "./domain_routes";
12
12
import { get_leaflet_data } from "./get_leaflet_data";
13
13
import { get_publication_data } from "./get_publication_data";
14
14
+
import { search_publication_names } from "./search_publication_names";
14
15
15
16
let supabase = createClient<Database>(
16
17
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
···
35
36
get_leaflet_subdomain_status,
36
37
get_leaflet_data,
37
38
get_publication_data,
39
39
+
search_publication_names,
38
40
];
39
41
export async function POST(
40
42
req: Request,
+32
app/api/rpc/[command]/search_publication_names.ts
···
1
1
+
import { z } from "zod";
2
2
+
import { makeRoute } from "../lib";
3
3
+
import type { Env } from "./route";
4
4
+
5
5
+
export type SearchPublicationNamesReturnType = Awaited<
6
6
+
ReturnType<(typeof search_publication_names)["handler"]>
7
7
+
>;
8
8
+
9
9
+
export const search_publication_names = makeRoute({
10
10
+
route: "search_publication_names",
11
11
+
input: z.object({
12
12
+
query: z.string(),
13
13
+
limit: z.number().optional().default(10),
14
14
+
}),
15
15
+
handler: async (
16
16
+
{ query, limit },
17
17
+
{ supabase }: Pick<Env, "supabase">,
18
18
+
) => {
19
19
+
// Search publications by name (case-insensitive partial match)
20
20
+
const { data: publications, error } = await supabase
21
21
+
.from("publications")
22
22
+
.select("uri, name, identity_did, record")
23
23
+
.ilike("name", `%${query}%`)
24
24
+
.limit(limit);
25
25
+
26
26
+
if (error) {
27
27
+
throw new Error(`Failed to search publications: ${error.message}`);
28
28
+
}
29
29
+
30
30
+
return { result: { publications } };
31
31
+
},
32
32
+
});
+3
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
···
22
22
let isStrikethrough = segment.facet?.find(
23
23
PubLeafletRichtextFacet.isStrikethrough,
24
24
);
25
25
+
let isDidMention = segment.facet?.find(
26
26
+
PubLeafletRichtextFacet.isDidMention,
27
27
+
);
25
28
let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline);
26
29
let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic);
27
30
let isHighlighted = segment.facet?.find(
+14
-32
components/Blocks/TextBlock/index.tsx
···
25
25
import { DotLoader } from "components/utils/DotLoader";
26
26
import { useMountProsemirror } from "./mountProsemirror";
27
27
import { schema } from "./schema";
28
28
-
import { MentionAutocomplete } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror";
28
28
+
import {
29
29
+
addMentionToEditor,
30
30
+
Mention,
31
31
+
MentionAutocomplete,
32
32
+
} from "app/[leaflet_id]/publish/BskyPostEditorProsemirror";
29
33
30
34
const HeadingStyle = {
31
35
1: "text-xl font-bold",
···
186
190
let editorState = useEditorStates(
187
191
(s) => s.editorStates[props.entityID],
188
192
)?.editor;
189
189
-
let { viewRef, handleMentionSelect, setMentionState } = useMentionState(
190
190
-
props.entityID,
191
191
-
);
193
193
+
let { viewRef, handleMentionSelect, setMentionState, mentionStateRef } =
194
194
+
useMentionState(props.entityID);
192
195
193
193
-
let { mountRef, actionTimeout } = useMountProsemirror({ props });
196
196
+
let { mountRef, actionTimeout } = useMountProsemirror({
197
197
+
props,
198
198
+
mentionStateRef,
199
199
+
});
194
200
195
201
return (
196
202
<>
···
458
464
const [mentionState, setMentionState] = useState<{
459
465
active: boolean;
460
466
range: { from: number; to: number } | null;
461
461
-
selectedMention: { handle: string; did: string } | null;
467
467
+
selectedMention: Mention | null;
462
468
}>({ active: false, range: null, selectedMention: null });
463
469
const mentionStateRef = useRef(mentionState);
464
470
mentionStateRef.current = mentionState;
465
471
466
472
const handleMentionSelect = useCallback(
467
467
-
(
468
468
-
mention: { handle: string; did: string },
469
469
-
range: { from: number; to: number },
470
470
-
) => {
473
473
+
(mention: Mention, range: { from: number; to: number }) => {
471
474
let view = useEditorStates.getState().editorStates[entityID]?.view;
472
475
if (!view) return;
473
473
-
const { from, to } = range;
474
474
-
const tr = view.state.tr;
475
475
-
476
476
-
// Delete the query text (keep the @)
477
477
-
tr.delete(from + 1, to);
478
478
-
479
479
-
// Insert the mention text after the @
480
480
-
const mentionText = mention.handle;
481
481
-
tr.insertText(mentionText, from + 1);
482
482
-
483
483
-
// Apply mention mark to @ and handle
484
484
-
tr.addMark(
485
485
-
from,
486
486
-
from + 1 + mentionText.length,
487
487
-
schema.marks.didMention.create({ did: mention.did }),
488
488
-
);
489
489
-
490
490
-
// Add a space after the mention
491
491
-
tr.insertText(" ", from + 1 + mentionText.length);
492
492
-
493
493
-
view.dispatch(tr);
494
494
-
view.focus();
476
476
+
addMentionToEditor(mention, range, view);
495
477
},
496
478
[],
497
479
);
+22
-9
components/Blocks/TextBlock/keymap.ts
···
24
24
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
25
25
import { isTextBlock } from "src/utils/isTextBlock";
26
26
import { UndoManager } from "src/undoManager";
27
27
+
import {
28
28
+
addMentionToEditor,
29
29
+
MentionState,
30
30
+
} from "app/[leaflet_id]/publish/BskyPostEditorProsemirror";
27
31
28
32
type PropsRef = RefObject<
29
33
BlockProps & {
···
35
39
propsRef: PropsRef,
36
40
repRef: RefObject<Replicache<ReplicacheMutators> | null>,
37
41
um: UndoManager,
38
38
-
multiLine?: boolean,
42
42
+
mentionStateRef: RefObject<MentionState>,
39
43
) =>
40
44
({
41
45
"Meta-b": toggleMark(schema.marks.strong),
···
138
142
),
139
143
"Shift-Backspace": backspace(propsRef, repRef),
140
144
Enter: (state, dispatch, view) => {
141
141
-
if (multiLine && state.doc.content.size - state.selection.anchor > 1)
142
142
-
return false;
143
143
-
return um.withUndoGroup(() =>
144
144
-
enter(propsRef, repRef)(state, dispatch, view),
145
145
-
);
145
145
+
return um.withUndoGroup(() => {
146
146
+
const currentMentionState = mentionStateRef.current;
147
147
+
if (
148
148
+
currentMentionState.active &&
149
149
+
currentMentionState.selectedMention &&
150
150
+
currentMentionState.range
151
151
+
) {
152
152
+
if (view)
153
153
+
addMentionToEditor(
154
154
+
currentMentionState.selectedMention,
155
155
+
currentMentionState.range,
156
156
+
view,
157
157
+
);
158
158
+
return true;
159
159
+
}
160
160
+
return enter(propsRef, repRef)(state, dispatch, view);
161
161
+
});
146
162
},
147
163
"Shift-Enter": (state, dispatch, view) => {
148
148
-
if (multiLine) {
149
149
-
return baseKeymap.Enter(state, dispatch, view);
150
150
-
}
151
164
return um.withUndoGroup(() =>
152
165
enter(propsRef, repRef)(state, dispatch, view),
153
166
);
+14
-2
components/Blocks/TextBlock/mountProsemirror.ts
···
23
23
import { useHandlePaste } from "./useHandlePaste";
24
24
import { BlockProps } from "../Block";
25
25
import { useEntitySetContext } from "components/EntitySetProvider";
26
26
+
import { MentionState } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror";
26
27
27
27
-
export function useMountProsemirror({ props }: { props: BlockProps }) {
28
28
+
export function useMountProsemirror({
29
29
+
props,
30
30
+
mentionStateRef,
31
31
+
}: {
32
32
+
props: BlockProps;
33
33
+
mentionStateRef: React.RefObject<MentionState>;
34
34
+
}) {
28
35
let { entityID, parent } = props;
29
36
let rep = useReplicache();
30
37
let mountRef = useRef<HTMLPreElement | null>(null);
···
44
51
useLayoutEffect(() => {
45
52
if (!mountRef.current) return;
46
53
47
47
-
const km = TextBlockKeymap(propsRef, repRef, rep.undoManager);
54
54
+
const km = TextBlockKeymap(
55
55
+
propsRef,
56
56
+
repRef,
57
57
+
rep.undoManager,
58
58
+
mentionStateRef,
59
59
+
);
48
60
const editor = EditorState.create({
49
61
schema: schema,
50
62
plugins: [
+26
-1
components/Blocks/TextBlock/schema.ts
···
103
103
return ["a", { href, target: "_blank" }, 0];
104
104
},
105
105
} as MarkSpec,
106
106
-
106
106
+
atMention: {
107
107
+
attrs: {
108
108
+
atURI: {},
109
109
+
},
110
110
+
inclusive: false,
111
111
+
parseDOM: [
112
112
+
{
113
113
+
tag: "span.atMention",
114
114
+
getAttrs(dom: HTMLElement) {
115
115
+
return {
116
116
+
atURI: dom.getAttribute("data-at-uri"),
117
117
+
};
118
118
+
},
119
119
+
},
120
120
+
],
121
121
+
toDOM(node) {
122
122
+
return [
123
123
+
"span",
124
124
+
{
125
125
+
class: "atMention text-accent-contrast",
126
126
+
"data-at-uri": node.attrs.atURI,
127
127
+
},
128
128
+
0,
129
129
+
];
130
130
+
},
131
131
+
} as MarkSpec,
107
132
didMention: {
108
133
attrs: {
109
134
did: {},