a tool for shared writing and social publishing
1"use client";
2
3import React, { useEffect, useState } from "react";
4import { TextBlockTypeToolbar } from "./TextBlockTypeToolbar";
5import { InlineLinkToolbar } from "./InlineLinkToolbar";
6import { useEditorStates } from "src/state/useEditorState";
7import { useUIState } from "src/useUIState";
8import { useEntity, useReplicache } from "src/replicache";
9import * as Tooltip from "@radix-ui/react-tooltip";
10import { addShortcut } from "src/shortcuts";
11import { ListToolbar } from "./ListToolbar";
12import { HighlightToolbar } from "./HighlightToolbar";
13import { TextToolbar } from "./TextToolbar";
14import { ImageToolbar } from "./ImageToolbar";
15import { MultiselectToolbar } from "./MultiSelectToolbar";
16import { TooltipButton } from "components/Buttons";
17import { TextAlignmentToolbar } from "./TextAlignmentToolbar";
18import { useIsMobile } from "src/hooks/isMobile";
19import { CloseTiny } from "components/Icons/CloseTiny";
20
21export type ToolbarTypes =
22 | "default"
23 | "multiselect"
24 | "highlight"
25 | "link"
26 | "heading"
27 | "text-alignment"
28 | "list"
29 | "linkBlock"
30 | "img-alt-text"
31 | "image";
32
33export const Toolbar = (props: {
34 pageID: string;
35 blockID: string;
36 blockType: string | null | undefined;
37}) => {
38 let [toolbarState, setToolbarState] = useState<ToolbarTypes>("default");
39
40 let activeEditor = useEditorStates((s) => s.editorStates[props.blockID]);
41 let selectedBlocks = useUIState((s) => s.selectedBlocks);
42
43 let lastUsedHighlight = useUIState((s) => s.lastUsedHighlight);
44 let setLastUsedHighlight = (color: "1" | "2" | "3") =>
45 useUIState.setState({
46 lastUsedHighlight: color,
47 });
48
49 useEffect(() => {
50 if (toolbarState !== "default") return;
51 let removeShortcut = addShortcut({
52 metaKey: true,
53 key: "k",
54 handler: () => {
55 setToolbarState("link");
56 },
57 });
58 return () => {
59 removeShortcut();
60 };
61 }, [toolbarState]);
62
63 let isTextBlock =
64 props.blockType === "heading" ||
65 props.blockType === "text" ||
66 props.blockType === "blockquote";
67
68 useEffect(() => {
69 if (selectedBlocks.length > 1) {
70 setToolbarState("multiselect");
71 return;
72 }
73 if (isTextBlock) {
74 setToolbarState("default");
75 }
76 if (props.blockType === "image") {
77 setToolbarState("image");
78 }
79 if (props.blockType === "button" || props.blockType === "datetime") {
80 setToolbarState("text-alignment");
81 } else null;
82 }, [props.blockType, selectedBlocks]);
83
84 let isMobile = useIsMobile();
85 return (
86 <Tooltip.Provider>
87 <div
88 className={`toolbar flex gap-2 items-center justify-between w-full
89 ${isMobile ? "h-[calc(15px+var(--safe-padding-bottom))]" : "h-[26px]"}`}
90 >
91 <div className="toolbarOptions flex gap-1 sm:gap-[6px] items-center grow">
92 {toolbarState === "default" ? (
93 <TextToolbar
94 lastUsedHighlight={lastUsedHighlight}
95 setToolbarState={(s) => {
96 setToolbarState(s);
97 }}
98 />
99 ) : toolbarState === "highlight" ? (
100 <HighlightToolbar
101 pageID={props.pageID}
102 onClose={() => setToolbarState("default")}
103 lastUsedHighlight={lastUsedHighlight}
104 setLastUsedHighlight={(color: "1" | "2" | "3") =>
105 setLastUsedHighlight(color)
106 }
107 />
108 ) : toolbarState === "list" ? (
109 <ListToolbar onClose={() => setToolbarState("default")} />
110 ) : toolbarState === "link" ? (
111 <InlineLinkToolbar
112 onClose={() => {
113 activeEditor?.view?.focus();
114 setToolbarState("default");
115 }}
116 />
117 ) : toolbarState === "heading" ? (
118 <TextBlockTypeToolbar onClose={() => setToolbarState("default")} />
119 ) : toolbarState === "text-alignment" ? (
120 <TextAlignmentToolbar />
121 ) : toolbarState === "image" ? (
122 <ImageToolbar setToolbarState={setToolbarState} />
123 ) : toolbarState === "multiselect" ? (
124 <MultiselectToolbar setToolbarState={setToolbarState} />
125 ) : null}
126 </div>
127 {/* if the thing is are you sure state, don't show the x... is each thing handling its own are you sure? theres no need for that */}
128
129 <button
130 className="toolbarBackToDefault hover:text-accent-contrast"
131 onMouseDown={(e) => {
132 e.preventDefault();
133 if (
134 toolbarState === "multiselect" ||
135 toolbarState === "image" ||
136 toolbarState === "default"
137 ) {
138 // close the toolbar
139 useUIState.setState(() => ({
140 focusedEntity: {
141 entityType: "page",
142 entityID: props.pageID,
143 },
144 selectedBlocks: [],
145 }));
146 } else {
147 if (props.blockType === "image") {
148 setToolbarState("image");
149 }
150 if (isTextBlock) {
151 setToolbarState("default");
152 }
153 }
154 }}
155 >
156 <CloseTiny />
157 </button>
158 </div>
159 </Tooltip.Provider>
160 );
161};
162
163export const ToolbarButton = (props: {
164 className?: string;
165 onClick?: (e: React.MouseEvent) => void;
166 tooltipContent: React.ReactNode;
167 children: React.ReactNode;
168 active?: boolean;
169 disabled?: boolean;
170 hiddenOnCanvas?: boolean;
171}) => {
172 let focusedEntity = useUIState((s) => s.focusedEntity);
173 let isDisabled = props.disabled;
174
175 let focusedEntityType = useEntity(
176 focusedEntity?.entityType === "page"
177 ? focusedEntity.entityID
178 : focusedEntity?.parent || null,
179 "page/type",
180 );
181 if (focusedEntityType?.data.value === "canvas" && props.hiddenOnCanvas)
182 return;
183 return (
184 <TooltipButton
185 onMouseDown={(e) => {
186 e.preventDefault();
187 props.onClick && props.onClick(e);
188 }}
189 disabled={isDisabled}
190 tooltipContent={props.tooltipContent}
191 className={`
192 flex items-center rounded-md border border-transparent
193 ${props.className}
194 ${
195 props.active && !isDisabled
196 ? "bg-border-light text-primary"
197 : isDisabled
198 ? "text-border cursor-not-allowed"
199 : "text-secondary hover:text-primary hover:border-border active:bg-border-light active:text-primary"
200 }
201 `}
202 >
203 {props.children}
204 </TooltipButton>
205 );
206};