Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
13 kB · 460 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461import type { Fact, ReplicacheMutators } from "src/replicache";import { useUIState } from "src/useUIState";
import { generateKeyBetween } from "fractional-indexing";import { focusPage } from "src/utils/focusPage";import { v7 } from "uuid";import { Replicache } from "replicache";import { useEditorStates } from "src/state/useEditorState";import { elementId } from "src/utils/elementId";import { UndoManager } from "src/undoManager";import { focusBlock } from "src/utils/focusBlock";import { usePollBlockUIState } from "./PollBlock/pollBlockState";import { focusElement } from "src/utils/focusElement";import { BlockBlueskySmall } from "components/Icons/BlockBlueskySmall";import { BlockButtonSmall } from "components/Icons/BlockButtonSmall";import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall";import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall";import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall";import { BlockEmbedSmall } from "components/Icons/BlockEmbedSmall";import { BlockImageSmall } from "components/Icons/BlockImageSmall";import { BlockMailboxSmall } from "components/Icons/BlockMailboxSmall";import { BlockPollSmall } from "components/Icons/BlockPollSmall";import { ParagraphSmall, Header1Small, Header2Small, Header3Small,} from "components/Icons/BlockTextSmall";import { LinkSmall } from "components/Icons/LinkSmall";import { BlockRSVPSmall } from "components/Icons/BlockRSVPSmall";import { ListUnorderedSmall, ListOrderedSmall,} from "components/Toolbar/ListToolbar";import { BlockMathSmall } from "components/Icons/BlockMathSmall";import { BlockCodeSmall } from "components/Icons/BlockCodeSmall";import { QuoteSmall } from "components/Icons/QuoteSmall";import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage";
type Props = { parent: string; entityID: string | null; position: string | null; nextPosition: string | null; factID?: string | undefined; first?: boolean; className?: string;};
async function createBlockWithType( rep: Replicache<ReplicacheMutators>, args: { entity_set: string; parent: string; position: string | null; nextPosition: string | null; entityID: string | null; }, type: Fact<"block/type">["data"]["value"],) { let entity;
if (!args.entityID) { entity = v7(); await rep?.mutate.addBlock({ parent: args.parent, factID: v7(), permission_set: args.entity_set, type: type, position: generateKeyBetween(args.position, args.nextPosition), newEntityID: entity, }); } else { entity = args.entityID; await rep?.mutate.assertFact({ entity, attribute: "block/type", data: { type: "block-type-union", value: type }, }); } return entity;}
function clearCommandSearchText(entityID: string) { useEditorStates.setState((s) => { let existingState = s.editorStates[entityID]; if (!existingState) { return s; }
let tr = existingState.editor.tr; tr.deleteRange(1, tr.doc.content.size - 1); return { editorStates: { ...s.editorStates, [entityID]: { ...existingState, editor: existingState.editor.apply(tr), }, }, }; });}
type Command = { name: string; icon: React.ReactNode; type: string; alternateNames?: string[]; hiddenInPublication?: boolean; onSelect: ( rep: Replicache<ReplicacheMutators>, props: Props & { entity_set: string }, undoManager: UndoManager, ) => Promise<any>;};export const blockCommands: Command[] = [ // please keep these in the order that they appear in the menu, grouped by type { name: "Text", icon: <ParagraphSmall />, type: "text", onSelect: async (rep, props, um) => { props.entityID && clearCommandSearchText(props.entityID); let entity = await createBlockWithType(rep, props, "text"); clearCommandSearchText(entity); }, }, { name: "Title", icon: <Header1Small />, type: "text", alternateNames: ["h1"], onSelect: async (rep, props, um) => { await setHeaderCommand(1, rep, props); }, }, { name: "Header", icon: <Header2Small />, type: "text", alternateNames: ["h2"], onSelect: async (rep, props, um) => { await setHeaderCommand(2, rep, props); }, }, { name: "Subheader", icon: <Header3Small />, type: "text", alternateNames: ["h3"], onSelect: async (rep, props, um) => { await setHeaderCommand(3, rep, props); }, }, { name: "Unordered List", icon: <ListUnorderedSmall />, alternateNames: ["Bulleted List"], type: "text", onSelect: async (rep, props, um) => { let entity = await createBlockWithType(rep, props, "text"); await rep?.mutate.assertFact({ entity, attribute: "block/is-list", data: { value: true, type: "boolean" }, }); clearCommandSearchText(entity); }, }, { name: "Ordered List", icon: <ListOrderedSmall />, type: "text", alternateNames: ["Numbered List"], onSelect: async (rep, props, um) => { let entity = await createBlockWithType(rep, props, "text"); await rep?.mutate.assertFact([ { entity, attribute: "block/is-list", data: { value: true, type: "boolean" }, }, { entity, attribute: "block/list-style", data: { value: "ordered", type: "list-style-union" }, }, ]); clearCommandSearchText(entity); }, }, { name: "Block Quote", icon: <QuoteSmall />, type: "text", onSelect: async (rep, props, um) => { if (props.entityID) clearCommandSearchText(props.entityID); let entity = await createBlockWithType(rep, props, "blockquote"); clearCommandSearchText(entity); }, },
{ name: "Image", icon: <BlockImageSmall />, type: "block", onSelect: async (rep, props, um) => { props.entityID && clearCommandSearchText(props.entityID); let entity = await createBlockWithType(rep, props, "image"); setTimeout(() => { let el = document.getElementById(elementId.block(entity).input); el?.focus(); }, 100); um.add({ undo: () => { focusTextBlock(entity); }, redo: () => { let el = document.getElementById(elementId.block(entity).input); el?.focus(); }, }); }, }, { name: "External Link", icon: <LinkSmall />, type: "block", onSelect: async (rep, props) => { createBlockWithType(rep, props, "link"); }, }, { name: "Button", icon: <BlockButtonSmall />, type: "block", onSelect: async (rep, props, um) => { props.entityID && clearCommandSearchText(props.entityID); await createBlockWithType(rep, props, "button"); um.add({ undo: () => { props.entityID && focusTextBlock(props.entityID); }, redo: () => {}, }); }, }, { name: "Horizontal Rule", icon: "—", type: "block", onSelect: async (rep, props, um) => { props.entityID && clearCommandSearchText(props.entityID); await createBlockWithType(rep, props, "horizontal-rule"); um.add({ undo: () => { props.entityID && focusTextBlock(props.entityID); }, redo: () => {}, }); }, }, { name: "Poll", icon: <BlockPollSmall />, type: "block", onSelect: async (rep, props, um) => { let entity = await createBlockWithType(rep, props, "poll"); let pollOptionEntity = v7(); await rep.mutate.addPollOption({ pollEntity: entity, pollOptionEntity, pollOptionName: "", factID: v7(), permission_set: props.entity_set, }); await rep.mutate.addPollOption({ pollEntity: entity, pollOptionEntity: v7(), pollOptionName: "", factID: v7(), permission_set: props.entity_set, }); usePollBlockUIState.setState((s) => ({ [entity]: { state: "editing" } })); setTimeout(() => { focusElement( document.getElementById( elementId.block(entity).pollInput(pollOptionEntity), ) as HTMLInputElement | null, ); }, 20); um.add({ undo: () => { props.entityID && focusTextBlock(props.entityID); }, redo: () => { setTimeout(() => { focusElement( document.getElementById( elementId.block(entity).pollInput(pollOptionEntity), ) as HTMLInputElement | null, ); }, 20); }, }); }, }, { name: "Embed Website", icon: <BlockEmbedSmall />, type: "block", onSelect: async (rep, props) => { createBlockWithType(rep, props, "embed"); }, }, { name: "Bluesky Post", icon: <BlockBlueskySmall />, type: "block", onSelect: async (rep, props) => { createBlockWithType(rep, props, "bluesky-post"); }, }, { name: "Math", icon: <BlockMathSmall />, type: "block", hiddenInPublication: false, onSelect: async (rep, props) => { createBlockWithType(rep, props, "math"); }, }, { name: "Code", icon: <BlockCodeSmall />, type: "block", hiddenInPublication: false, onSelect: async (rep, props) => { let entity = await createBlockWithType(rep, props, "code"); let lastLang = localStorage.getItem(LAST_USED_CODE_LANGUAGE_KEY); if (lastLang) { await rep.mutate.assertFact({ entity, attribute: "block/code-language", data: { type: "string", value: lastLang }, }); } }, },
// EVENT STUFF { name: "Date and Time", icon: <BlockCalendarSmall />, type: "event", hiddenInPublication: true, onSelect: (rep, props) => { props.entityID && clearCommandSearchText(props.entityID); return createBlockWithType(rep, props, "datetime"); }, },
// PAGE TYPES
{ name: "New Page", icon: <BlockDocPageSmall />, type: "page", onSelect: async (rep, props, um) => { props.entityID && clearCommandSearchText(props.entityID); let entity = await createBlockWithType(rep, props, "card");
let newPage = v7(); await rep?.mutate.addPageLinkBlock({ blockEntity: entity, firstBlockFactID: v7(), firstBlockEntity: v7(), pageEntity: newPage, type: "doc", permission_set: props.entity_set, });
useUIState.getState().openPage(props.parent, newPage); um.add({ undo: () => { useUIState.getState().closePage(newPage); setTimeout( () => focusBlock( { parent: props.parent, value: entity, type: "text" }, { type: "end" }, ), 100, ); }, redo: () => { useUIState.getState().openPage(props.parent, newPage); focusPage(newPage, rep, "focusFirstBlock"); }, }); focusPage(newPage, rep, "focusFirstBlock"); }, }, { name: "New Canvas", icon: <BlockCanvasPageSmall />, type: "page", onSelect: async (rep, props, um) => { props.entityID && clearCommandSearchText(props.entityID); let entity = await createBlockWithType(rep, props, "card");
let newPage = v7(); await rep?.mutate.addPageLinkBlock({ type: "canvas", blockEntity: entity, firstBlockFactID: v7(), firstBlockEntity: v7(), pageEntity: newPage, permission_set: props.entity_set, }); useUIState.getState().openPage(props.parent, newPage); focusPage(newPage, rep, "focusFirstBlock"); um.add({ undo: () => { useUIState.getState().closePage(newPage); setTimeout( () => focusBlock( { parent: props.parent, value: entity, type: "text" }, { type: "end" }, ), 100, ); }, redo: () => { useUIState.getState().openPage(props.parent, newPage); focusPage(newPage, rep, "focusFirstBlock"); }, }); }, },];
async function setHeaderCommand( level: number, rep: Replicache<ReplicacheMutators>, props: Props & { entity_set: string },) { let entity = await createBlockWithType(rep, props, "heading"); await rep.mutate.assertFact({ entity, attribute: "block/heading-level", data: { type: "number", value: level }, }); clearCommandSearchText(entity);}function focusTextBlock(entityID: string) { document.getElementById(elementId.block(entityID).text)?.focus();}