a tool for shared writing and social publishing
1import { schema } from "components/Blocks/TextBlock/schema";
2import { TextSelection } from "prosemirror-state";
3import { useUIState } from "src/useUIState";
4import { ToolbarButton } from ".";
5import { useEffect, useState } from "react";
6import { Separator } from "components/Layout";
7import { setEditorState, useEditorStates } from "src/state/useEditorState";
8import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark";
9import { findMarkRange } from "src/utils/prosemirror/findMarkRange";
10import { ensureProtocol } from "src/utils/ensureProtocol";
11import { Input } from "components/Input";
12import { useReplicache } from "src/replicache";
13import { CheckTiny } from "components/Icons/CheckTiny";
14import { LinkSmall } from "components/Icons/LinkSmall";
15
16export function LinkButton(props: { setToolbarState: (s: "link") => void }) {
17 let focusedBlock = useUIState((s) => s.focusedEntity);
18 let focusedEditor = useEditorStates((s) =>
19 focusedBlock ? s.editorStates[focusedBlock.entityID] : null,
20 );
21 let isLink;
22 if (focusedEditor) {
23 let { to, from, $cursor } = focusedEditor.editor.selection as TextSelection;
24 if ($cursor) isLink = !!schema.marks.link.isInSet($cursor.marks());
25 if (to !== from)
26 isLink = !!rangeHasMark(
27 focusedEditor.editor,
28 schema.marks.link,
29 from,
30 to,
31 );
32 }
33
34 return (
35 <ToolbarButton
36 active={isLink}
37 onClick={(e) => {
38 e.preventDefault();
39 props.setToolbarState("link");
40 }}
41 disabled={
42 !focusedEditor || (focusedEditor?.editor.selection.empty && !isLink)
43 }
44 tooltipContent={
45 <div className="text-accent-contrast underline">Inline Link</div>
46 }
47 >
48 <LinkSmall />
49 </ToolbarButton>
50 );
51}
52
53export function InlineLinkToolbar(props: { onClose: () => void }) {
54 let focusedBlock = useUIState((s) => s.focusedEntity);
55 let focusedEditor = useEditorStates((s) =>
56 focusedBlock ? s.editorStates[focusedBlock.entityID] : null,
57 );
58 let { undoManager } = useReplicache();
59 useEffect(() => {
60 if (focusedEditor) {
61 let isLink;
62 let { to, from, $cursor } = focusedEditor.editor
63 .selection as TextSelection;
64 if ($cursor) isLink = !!schema.marks.link.isInSet($cursor.marks());
65 if (to !== from)
66 isLink = !!rangeHasMark(
67 focusedEditor.editor,
68 schema.marks.link,
69 from,
70 to,
71 );
72 if (isLink) return;
73 }
74 if (focusedEditor?.editor.selection.empty) props.onClose();
75 }, [focusedEditor, props]);
76 let content = "";
77 let start: number | null = null;
78 let end: number | null = null;
79 if (focusedEditor) {
80 let { to, from, $cursor } = focusedEditor.editor.selection as TextSelection;
81 if (to !== from) {
82 start = from;
83 end = to;
84 } else {
85 let markRange = findMarkRange(
86 focusedEditor.editor.doc,
87 schema.marks.link,
88 from,
89 );
90 start = markRange.start;
91 end = markRange.end;
92 }
93 if ($cursor) {
94 let link = $cursor.marks().find((f) => f.type === schema.marks.link);
95 if (link) {
96 content = link.attrs.href;
97 }
98 }
99 }
100 let [linkValue, setLinkValue] = useState(content);
101 let setLink = () => {
102 let href = ensureProtocol(linkValue);
103
104 let editor = focusedEditor?.editor;
105 if (!editor || start === null || !end || !focusedBlock) return;
106 let tr = editor.tr;
107 tr.addMark(start, end, schema.marks.link.create({ href }));
108 tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
109
110 let oldState = editor;
111 let newState = editor.apply(tr);
112 undoManager.add({
113 undo: () => {
114 if (!focusedEditor?.view?.hasFocus()) focusedEditor?.view?.focus();
115 setEditorState(focusedBlock.entityID, {
116 editor: oldState,
117 });
118 },
119 redo: () => {
120 if (!focusedEditor?.view?.hasFocus()) focusedEditor?.view?.focus();
121 setEditorState(focusedBlock.entityID, {
122 editor: newState,
123 });
124 },
125 });
126 setEditorState(focusedBlock?.entityID, {
127 editor: newState,
128 });
129 props.onClose();
130 };
131
132 return (
133 <div className="w-full flex items-center gap-[6px] grow">
134 <LinkSmall />
135 <Separator classname="h-6!" />
136 <Input
137 autoFocus
138 className="w-full grow bg-transparent border-none outline-hidden "
139 placeholder="www.example.com"
140 value={linkValue}
141 onChange={(e) => setLinkValue(e.target.value)}
142 onKeyDown={(e) => {
143 if (e.key === "Enter") {
144 e.preventDefault();
145 setLink();
146 }
147 if (e.key === "Escape") {
148 props.onClose();
149 }
150 }}
151 />
152 {/*
153 TODO:
154 to avoid all sort of messiness, editing any portion of link will edit the
155 entire range that includes the link rather than just the link text that is selected.
156
157 ALSO TODO:
158 if there is already a link mark, the input should be prefilled with the link value
159 and the check mark should be a garbage can to remove the link.
160
161 if the user changes the link, then the button reverts to a check mark.
162 */}
163 <div className="flex items-center gap-3 w-4">
164 <button
165 disabled={!linkValue || linkValue === ""}
166 className="hover:text-accent-contrast -mr-6 disabled:text-border"
167 onMouseDown={(e) => {
168 e.preventDefault();
169 setLink();
170 }}
171 >
172 <CheckTiny />
173 </button>
174 </div>
175 </div>
176 );
177}
178