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
27
pulls
pipelines
add toolbar stuff
awarm.space
6 days ago
4a8480f3
bf1a2f50
+278
-7
10 changed files
expand all
collapse all
unified
split
app
[leaflet_id]
Footer.tsx
globals.css
components
DesktopFooter.tsx
Footnotes
FootnoteContext.tsx
FootnoteEditor.tsx
FootnoteSideColumnLayout.tsx
usePageFootnotes.ts
Toolbar
FootnoteTextToolbar.tsx
FootnoteToolbarWrapper.tsx
src
useUIState.ts
+12
app/[leaflet_id]/Footer.tsx
···
4
4
import { Media } from "components/Media";
5
5
import { ThemePopover } from "components/ThemeManager/ThemeSetter";
6
6
import { Toolbar } from "components/Toolbar";
7
7
+
import { FootnoteToolbar } from "components/Toolbar/FootnoteToolbarWrapper";
7
8
import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions";
8
9
import { HomeButton } from "app/[leaflet_id]/actions/HomeButton";
9
10
import { PublishButton } from "./actions/PublishButton";
···
55
56
blockID={focusedBlock.entityID}
56
57
blockType={blockType}
57
58
/>
59
59
+
</div>
60
60
+
) : focusedBlock &&
61
61
+
focusedBlock.entityType === "footnote" &&
62
62
+
entity_set.permissions.write ? (
63
63
+
<div
64
64
+
className="w-full z-10 p-2 flex bg-bg-page pwa-padding-bottom"
65
65
+
onMouseDown={(e) => {
66
66
+
if (e.currentTarget === e.target) e.preventDefault();
67
67
+
}}
68
68
+
>
69
69
+
<FootnoteToolbar pageID={focusedBlock.parent} />
58
70
</div>
59
71
) : entity_set.permissions.write ? (
60
72
<Footer>
+4
-2
app/globals.css
···
544
544
transition: opacity 200ms ease;
545
545
}
546
546
.footnote-side-item:hover,
547
547
-
.footnote-side-item:focus-within {
547
547
+
.footnote-side-item:focus-within,
548
548
+
.footnote-side-item.footnote-side-focused {
548
549
max-height: 40em;
549
550
}
550
551
.footnote-side-item:hover::after,
551
551
-
.footnote-side-item:focus-within::after {
552
552
+
.footnote-side-item:focus-within::after,
553
553
+
.footnote-side-item.footnote-side-focused::after {
552
554
opacity: 0;
553
555
}
+15
components/DesktopFooter.tsx
···
2
2
import { useUIState } from "src/useUIState";
3
3
import { Media } from "./Media";
4
4
import { Toolbar } from "./Toolbar";
5
5
+
import { FootnoteToolbar } from "./Toolbar/FootnoteToolbarWrapper";
5
6
import { useEntitySetContext } from "./EntitySetProvider";
6
7
import { focusBlock } from "src/utils/focusBlock";
7
8
import { hasBlockToolbar } from "app/[leaflet_id]/Footer";
···
17
18
18
19
let blockType = useEntity(focusedEntity?.entityID || null, "block/type")?.data
19
20
.value;
21
21
+
22
22
+
let isFootnoteFocused =
23
23
+
focusedEntity?.entityType === "footnote" &&
24
24
+
focusedEntity.parent === props.pageID;
20
25
21
26
return (
22
27
<Media
···
41
46
/>
42
47
</div>
43
48
)}
49
49
+
{isFootnoteFocused && entity_set.permissions.write && (
50
50
+
<div
51
51
+
className="pointer-events-auto w-fit mx-auto py-1 px-3 h-9 bg-bg-page border border-border rounded-full shadow-sm"
52
52
+
onMouseDown={(e) => {
53
53
+
if (e.currentTarget === e.target) e.preventDefault();
54
54
+
}}
55
55
+
>
56
56
+
<FootnoteToolbar pageID={props.pageID} />
57
57
+
</div>
58
58
+
)}
44
59
</Media>
45
60
);
46
61
}
+2
components/Footnotes/FootnoteContext.tsx
···
2
2
import type { FootnoteInfo } from "./usePageFootnotes";
3
3
4
4
type FootnoteContextValue = {
5
5
+
pageID: string;
5
6
footnotes: FootnoteInfo[];
6
7
indexMap: Record<string, number>;
7
8
};
8
9
9
10
export const FootnoteContext = createContext<FootnoteContextValue>({
11
11
+
pageID: "",
10
12
footnotes: [],
11
13
indexMap: {},
12
14
});
+86
-2
components/Footnotes/FootnoteEditor.tsx
···
1
1
import { useLayoutEffect, useRef } from "react";
2
2
-
import { EditorState } from "prosemirror-state";
2
2
+
import { EditorState, TextSelection } from "prosemirror-state";
3
3
import { EditorView } from "prosemirror-view";
4
4
import { baseKeymap, toggleMark } from "prosemirror-commands";
5
5
import { keymap } from "prosemirror-keymap";
···
7
7
import { schema } from "components/Blocks/TextBlock/schema";
8
8
import { useReplicache } from "src/replicache";
9
9
import { autolink } from "components/Blocks/TextBlock/autolink-plugin";
10
10
+
import { betterIsUrl } from "src/utils/isURL";
10
11
import {
11
12
useYJSValue,
12
13
trackUndoRedo,
13
14
} from "components/Blocks/TextBlock/mountProsemirror";
14
15
import { CloseTiny } from "components/Icons/CloseTiny";
15
16
import { FootnoteItemLayout } from "./FootnoteItemLayout";
17
17
+
import { useEditorStates } from "src/state/useEditorState";
18
18
+
import { useUIState } from "src/useUIState";
19
19
+
import { useFootnoteContext } from "./FootnoteContext";
16
20
17
21
export function FootnoteEditor(props: {
18
22
footnoteEntityID: string;
···
25
29
let rep = useReplicache();
26
30
let value = useYJSValue(props.footnoteEntityID);
27
31
let actionTimeout = useRef<number | null>(null);
32
32
+
let { pageID } = useFootnoteContext();
28
33
29
34
useLayoutEffect(() => {
30
35
if (!mountRef.current || !value) return;
···
66
71
{
67
72
state,
68
73
editable: () => props.editable,
74
74
+
handlePaste: (view, e) => {
75
75
+
let text = e.clipboardData?.getData("text");
76
76
+
if (text && betterIsUrl(text)) {
77
77
+
let selection = view.state.selection as TextSelection;
78
78
+
let tr = view.state.tr;
79
79
+
let { from, to } = selection;
80
80
+
if (selection.empty) {
81
81
+
tr.insertText(text, selection.from);
82
82
+
tr.addMark(
83
83
+
from,
84
84
+
from + text.length,
85
85
+
schema.marks.link.create({ href: text }),
86
86
+
);
87
87
+
} else {
88
88
+
tr.addMark(from, to, schema.marks.link.create({ href: text }));
89
89
+
}
90
90
+
view.dispatch(tr);
91
91
+
return true;
92
92
+
}
93
93
+
},
94
94
+
handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => {
95
95
+
if (!direct) return;
96
96
+
if (node.nodeSize - 2 <= _pos) return;
97
97
+
const nodeAt1 = node.nodeAt(_pos - 1);
98
98
+
const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0));
99
99
+
let linkMark =
100
100
+
nodeAt1?.marks.find((f) => f.type === schema.marks.link) ||
101
101
+
nodeAt2?.marks.find((f) => f.type === schema.marks.link);
102
102
+
if (linkMark) {
103
103
+
window.open(linkMark.attrs.href, "_blank");
104
104
+
return;
105
105
+
}
106
106
+
},
69
107
dispatchTransaction(this: EditorView, tr) {
70
108
let oldState = this.state;
71
109
let newState = this.state.apply(tr);
72
110
this.updateState(newState);
73
111
112
112
+
useEditorStates.setState((s) => ({
113
113
+
editorStates: {
114
114
+
...s.editorStates,
115
115
+
[props.footnoteEntityID]: {
116
116
+
editor: newState,
117
117
+
view: this,
118
118
+
},
119
119
+
},
120
120
+
}));
121
121
+
74
122
trackUndoRedo(
75
123
tr,
76
124
rep.undoManager,
···
88
136
},
89
137
);
90
138
139
139
+
// Register editor state
140
140
+
useEditorStates.setState((s) => ({
141
141
+
editorStates: {
142
142
+
...s.editorStates,
143
143
+
[props.footnoteEntityID]: {
144
144
+
editor: view.state,
145
145
+
view,
146
146
+
},
147
147
+
},
148
148
+
}));
149
149
+
150
150
+
// Subscribe to external state changes (e.g. link toolbar)
151
151
+
let unsubscribe = useEditorStates.subscribe((s) => {
152
152
+
let editorState = s.editorStates[props.footnoteEntityID];
153
153
+
if (editorState?.editor)
154
154
+
editorState.view?.updateState(editorState.editor);
155
155
+
});
156
156
+
157
157
+
// Set focusedEntity on focus
158
158
+
let handleFocus = () => {
159
159
+
useUIState.setState({
160
160
+
focusedEntity: {
161
161
+
entityType: "footnote",
162
162
+
entityID: props.footnoteEntityID,
163
163
+
parent: pageID,
164
164
+
},
165
165
+
});
166
166
+
};
167
167
+
view.dom.addEventListener("focus", handleFocus);
168
168
+
91
169
if (props.autoFocus) {
92
170
setTimeout(() => view.focus(), 50);
93
171
}
94
172
95
173
return () => {
174
174
+
unsubscribe();
175
175
+
view.dom.removeEventListener("focus", handleFocus);
96
176
view.destroy();
177
177
+
useEditorStates.setState((s) => {
178
178
+
let { [props.footnoteEntityID]: _, ...rest } = s.editorStates;
179
179
+
return { editorStates: rest };
180
180
+
});
97
181
};
98
98
-
}, [props.footnoteEntityID, value, props.editable, props.autoFocus, rep.undoManager]);
182
182
+
}, [props.footnoteEntityID, value, props.editable, props.autoFocus, rep.undoManager, pageID]);
99
183
100
184
return (
101
185
<FootnoteItemLayout
+7
-1
components/Footnotes/FootnoteSideColumnLayout.tsx
···
1
1
"use client";
2
2
3
3
import { useEffect, useRef, useState, useCallback, ReactNode } from "react";
4
4
+
import { useUIState } from "src/useUIState";
4
5
5
6
export type FootnoteSideItem = {
6
7
id: string;
···
131
132
}) {
132
133
let ref = useRef<HTMLDivElement>(null);
133
134
let [overflows, setOverflows] = useState(false);
135
135
+
let isFocused = useUIState(
136
136
+
(s) =>
137
137
+
s.focusedEntity?.entityType === "footnote" &&
138
138
+
s.focusedEntity.entityID === props.id,
139
139
+
);
134
140
135
141
useEffect(() => {
136
142
let el = ref.current;
···
158
164
<div
159
165
ref={ref}
160
166
data-footnote-side-id={props.id}
161
161
-
className={`absolute left-0 right-0 text-xs footnote-side-enter footnote-side-item${overflows ? " has-overflow" : ""}`}
167
167
+
className={`absolute left-0 right-0 text-xs footnote-side-enter footnote-side-item${overflows ? " has-overflow" : ""}${isFocused ? " footnote-side-focused" : ""}`}
162
168
style={{ top: props.top }}
163
169
>
164
170
{props.children}
+2
-2
components/Footnotes/usePageFootnotes.ts
···
39
39
}
40
40
}
41
41
42
42
-
return { footnotes, indexMap };
42
42
+
return { pageID, footnotes, indexMap };
43
43
},
44
44
{ dependencies: [pageID] },
45
45
);
46
46
47
47
-
return data || { footnotes: [], indexMap: {} as Record<string, number> };
47
47
+
return data || { pageID, footnotes: [], indexMap: {} as Record<string, number> };
48
48
}
+71
components/Toolbar/FootnoteTextToolbar.tsx
···
1
1
+
import { Separator, ShortcutKey } from "components/Layout";
2
2
+
import { metaKey } from "src/utils/metaKey";
3
3
+
import { LinkButton } from "./InlineLinkToolbar";
4
4
+
import { TextDecorationButton } from "./TextDecorationButton";
5
5
+
import { schema } from "components/Blocks/TextBlock/schema";
6
6
+
import { BoldSmall, ItalicSmall, StrikethroughSmall } from "./TextToolbar";
7
7
+
import { isMac } from "src/utils/isDevice";
8
8
+
import { ToolbarTypes } from ".";
9
9
+
10
10
+
export const FootnoteTextToolbar = (props: {
11
11
+
setToolbarState: (s: ToolbarTypes) => void;
12
12
+
}) => {
13
13
+
return (
14
14
+
<>
15
15
+
<TextDecorationButton
16
16
+
tooltipContent={
17
17
+
<div className="flex flex-col gap-1 justify-center">
18
18
+
<div className="text-center">Bold </div>
19
19
+
<div className="flex gap-1">
20
20
+
<ShortcutKey>{metaKey()}</ShortcutKey> +{" "}
21
21
+
<ShortcutKey> B </ShortcutKey>
22
22
+
</div>
23
23
+
</div>
24
24
+
}
25
25
+
mark={schema.marks.strong}
26
26
+
icon={<BoldSmall />}
27
27
+
/>
28
28
+
<TextDecorationButton
29
29
+
tooltipContent={
30
30
+
<div className="flex flex-col gap-1 justify-center">
31
31
+
<div className="italic font-normal text-center">Italic</div>
32
32
+
<div className="flex gap-1">
33
33
+
<ShortcutKey>{metaKey()}</ShortcutKey> +{" "}
34
34
+
<ShortcutKey> I </ShortcutKey>
35
35
+
</div>
36
36
+
</div>
37
37
+
}
38
38
+
mark={schema.marks.em}
39
39
+
icon={<ItalicSmall />}
40
40
+
/>
41
41
+
<TextDecorationButton
42
42
+
tooltipContent={
43
43
+
<div className="flex flex-col gap-1 justify-center">
44
44
+
<div className="text-center font-normal line-through">
45
45
+
Strikethrough
46
46
+
</div>
47
47
+
<div className="flex gap-1">
48
48
+
{isMac() ? (
49
49
+
<>
50
50
+
<ShortcutKey>⌘</ShortcutKey> +{" "}
51
51
+
<ShortcutKey> Ctrl </ShortcutKey> +{" "}
52
52
+
<ShortcutKey> X </ShortcutKey>
53
53
+
</>
54
54
+
) : (
55
55
+
<>
56
56
+
<ShortcutKey> Ctrl </ShortcutKey> +{" "}
57
57
+
<ShortcutKey> Meta </ShortcutKey> +{" "}
58
58
+
<ShortcutKey> X </ShortcutKey>
59
59
+
</>
60
60
+
)}
61
61
+
</div>
62
62
+
</div>
63
63
+
}
64
64
+
mark={schema.marks.strikethrough}
65
65
+
icon={<StrikethroughSmall />}
66
66
+
/>
67
67
+
<Separator classname="h-6!" />
68
68
+
<LinkButton setToolbarState={props.setToolbarState} />
69
69
+
</>
70
70
+
);
71
71
+
};
+77
components/Toolbar/FootnoteToolbarWrapper.tsx
···
1
1
+
"use client";
2
2
+
3
3
+
import React, { useEffect, useState } from "react";
4
4
+
import { InlineLinkToolbar } from "./InlineLinkToolbar";
5
5
+
import { useEditorStates } from "src/state/useEditorState";
6
6
+
import { useUIState } from "src/useUIState";
7
7
+
import * as Tooltip from "@radix-ui/react-tooltip";
8
8
+
import { addShortcut } from "src/shortcuts";
9
9
+
import { FootnoteTextToolbar } from "./FootnoteTextToolbar";
10
10
+
import { useIsMobile } from "src/hooks/isMobile";
11
11
+
import { CloseTiny } from "components/Icons/CloseTiny";
12
12
+
13
13
+
type FootnoteToolbarState = "default" | "link";
14
14
+
15
15
+
export const FootnoteToolbar = (props: { pageID: string }) => {
16
16
+
let [toolbarState, setToolbarState] = useState<FootnoteToolbarState>("default");
17
17
+
let focusedEntity = useUIState((s) => s.focusedEntity);
18
18
+
let activeEditor = useEditorStates((s) =>
19
19
+
focusedEntity ? s.editorStates[focusedEntity.entityID] : null,
20
20
+
);
21
21
+
22
22
+
useEffect(() => {
23
23
+
if (toolbarState !== "default") return;
24
24
+
let removeShortcut = addShortcut({
25
25
+
metaKey: true,
26
26
+
key: "k",
27
27
+
handler: () => {
28
28
+
setToolbarState("link");
29
29
+
},
30
30
+
});
31
31
+
return () => {
32
32
+
removeShortcut();
33
33
+
};
34
34
+
}, [toolbarState]);
35
35
+
36
36
+
let isMobile = useIsMobile();
37
37
+
return (
38
38
+
<Tooltip.Provider>
39
39
+
<div
40
40
+
className={`toolbar flex gap-2 items-center justify-between w-full
41
41
+
${isMobile ? "h-[calc(15px+var(--safe-padding-bottom))]" : "h-[26px]"}`}
42
42
+
>
43
43
+
<div className="toolbarOptions flex gap-1 sm:gap-[6px] items-center grow">
44
44
+
{toolbarState === "default" ? (
45
45
+
<FootnoteTextToolbar setToolbarState={setToolbarState} />
46
46
+
) : toolbarState === "link" ? (
47
47
+
<InlineLinkToolbar
48
48
+
onClose={() => {
49
49
+
activeEditor?.view?.focus();
50
50
+
setToolbarState("default");
51
51
+
}}
52
52
+
/>
53
53
+
) : null}
54
54
+
</div>
55
55
+
<button
56
56
+
className="toolbarBackToDefault hover:text-accent-contrast"
57
57
+
onMouseDown={(e) => {
58
58
+
e.preventDefault();
59
59
+
if (toolbarState === "default") {
60
60
+
useUIState.setState(() => ({
61
61
+
focusedEntity: {
62
62
+
entityType: "page",
63
63
+
entityID: props.pageID,
64
64
+
},
65
65
+
selectedBlocks: [],
66
66
+
}));
67
67
+
} else {
68
68
+
setToolbarState("default");
69
69
+
}
70
70
+
}}
71
71
+
>
72
72
+
<CloseTiny />
73
73
+
</button>
74
74
+
</div>
75
75
+
</Tooltip.Provider>
76
76
+
);
77
77
+
};
+2
src/useUIState.ts
···
10
10
focusedEntity: null as
11
11
| { entityType: "page"; entityID: string }
12
12
| { entityType: "block"; entityID: string; parent: string }
13
13
+
| { entityType: "footnote"; entityID: string; parent: string }
13
14
| null,
14
15
foldedBlocks: [] as string[],
15
16
openPages: [] as string[],
···
47
48
b:
48
49
| { entityType: "page"; entityID: string }
49
50
| { entityType: "block"; entityID: string; parent: string }
51
51
+
| { entityType: "footnote"; entityID: string; parent: string }
50
52
| null,
51
53
) => set(() => ({ focusedEntity: b })),
52
54
setSelectedBlock: (block: SelectedBlock) =>