a tool for shared writing and social publishing

add toolbar stuff

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