a tool for shared writing and social publishing

Feature: math and code blocks (#152)

* add initial math block

Needs, styling mainly! and some logic for how we show empty blocks, etc

* add @types/katex

* light styling to the math block

* add code block

* added some styles to code blocks

* make cursor handling and themes work

* support copying and pasting code blocks

* simplify focusBlock for math/code

* some styling

* add ``` for code blocks

* handle copy/paste for math blocks too

* handle pasting markdown code blocks

* support rendering to published post

---------

Co-authored-by: celine <celine@hyperlink.academy>

authored by awarm.space celine and committed by GitHub 67bb09ae a4a05d8c

+38 -7
actions/publishToPublication.ts
··· 14 14 PubLeafletPagesLinearDocument, 15 15 PubLeafletRichtextFacet, 16 16 PubLeafletBlocksWebsite, 17 + PubLeafletBlocksCode, 18 + PubLeafletBlocksMath, 17 19 } from "lexicons/api"; 18 20 import { Block } from "components/Blocks/Block"; 19 21 import { TID } from "@atproto/common"; ··· 95 97 blocks, 96 98 imageMap, 97 99 scan, 100 + root_entity, 98 101 ); 99 102 100 103 let existingRecord = ··· 149 152 blocks: Block[], 150 153 imageMap: Map<string, BlobRef>, 151 154 scan: ReturnType<typeof scanIndexLocal>, 155 + root_entity: string, 152 156 ): PubLeafletPagesLinearDocument.Block[] { 153 157 let parsedBlocks = parseBlocksToList(blocks); 154 158 return parsedBlocks.flatMap((blockOrList) => { ··· 162 166 : alignmentValue === "right" 163 167 ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" 164 168 : undefined; 165 - let b = blockToRecord(blockOrList.block, imageMap, scan); 169 + let b = blockToRecord(blockOrList.block, imageMap, scan, root_entity); 166 170 if (!b) return []; 167 171 let block: PubLeafletPagesLinearDocument.Block = { 168 172 $type: "pub.leaflet.pages.linearDocument#block", ··· 175 179 $type: "pub.leaflet.pages.linearDocument#block", 176 180 block: { 177 181 $type: "pub.leaflet.blocks.unorderedList", 178 - children: childrenToRecord(blockOrList.children, imageMap, scan), 182 + children: childrenToRecord( 183 + blockOrList.children, 184 + imageMap, 185 + scan, 186 + root_entity, 187 + ), 179 188 }, 180 189 }; 181 190 return [block]; ··· 187 196 children: List[], 188 197 imageMap: Map<string, BlobRef>, 189 198 scan: ReturnType<typeof scanIndexLocal>, 199 + root_entity: string, 190 200 ) { 191 201 return children.flatMap((child) => { 192 - let content = blockToRecord(child.block, imageMap, scan); 202 + let content = blockToRecord(child.block, imageMap, scan, root_entity); 193 203 if (!content) return []; 194 204 let record: PubLeafletBlocksUnorderedList.ListItem = { 195 205 $type: "pub.leaflet.blocks.unorderedList#listItem", 196 206 content, 197 - children: childrenToRecord(child.children, imageMap, scan), 207 + children: childrenToRecord(child.children, imageMap, scan, root_entity), 198 208 }; 199 209 return record; 200 210 }); ··· 203 213 b: Block, 204 214 imageMap: Map<string, BlobRef>, 205 215 scan: ReturnType<typeof scanIndexLocal>, 216 + root_entity: string, 206 217 ) { 207 218 const getBlockContent = (b: string) => { 208 219 let [content] = scan.eav(b, "block/text"); ··· 219 230 b.type !== "text" && 220 231 b.type !== "heading" && 221 232 b.type !== "image" && 222 - b.type !== "link" 233 + b.type !== "link" && 234 + b.type !== "code" && 235 + b.type !== "math" 223 236 ) 224 237 return; 225 - let alignmentValue = 226 - scan.eav(b.value, "block/text-alignment")[0]?.data.value || "left"; 227 238 228 239 if (b.type === "heading") { 229 240 let [headingLevel] = scan.eav(b.value, "block/heading-level"); ··· 279 290 src: src.data.value, 280 291 description: description.data.value, 281 292 title: title.data.value, 293 + }; 294 + return block; 295 + } 296 + if (b.type === "code") { 297 + let [language] = scan.eav(b.value, "block/code-language"); 298 + let [code] = scan.eav(b.value, "block/code"); 299 + let [theme] = scan.eav(root_entity, "theme/code-theme"); 300 + let block: $Typed<PubLeafletBlocksCode.Main> = { 301 + $type: "pub.leaflet.blocks.code", 302 + language: language?.data.value, 303 + plaintext: code?.data.value || "", 304 + syntaxHighlightingTheme: theme?.data.value, 305 + }; 306 + return block; 307 + } 308 + if (b.type === "math") { 309 + let [math] = scan.eav(b.value, "block/math"); 310 + let block: $Typed<PubLeafletBlocksMath.Main> = { 311 + $type: "pub.leaflet.blocks.math", 312 + tex: math?.data.value || "", 282 313 }; 283 314 return block; 284 315 }
+10
app/globals.css
··· 157 157 ); 158 158 } 159 159 160 + pre.shiki code { 161 + display: block; 162 + } 163 + 164 + pre.shiki { 165 + @apply p-2; 166 + @apply rounded-md; 167 + @apply overflow-auto; 168 + } 169 + 160 170 .highlight { 161 171 @apply px-[1px]; 162 172 @apply py-[1px];
+21 -1
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 1 1 import { 2 + PubLeafletBlocksMath, 3 + PubLeafletBlocksCode, 2 4 PubLeafletBlocksHeader, 3 5 PubLeafletBlocksImage, 4 6 PubLeafletBlocksText, ··· 12 14 import { Popover } from "components/Popover"; 13 15 import { theme } from "tailwind.config"; 14 16 import { ImageAltSmall } from "components/Icons/ImageAlt"; 17 + import { codeToHtml } from "shiki"; 18 + import Katex from "katex"; 19 + import { StaticMathBlock } from "./StaticMathBlock"; 15 20 16 21 export function PostContent({ 17 22 blocks, ··· 29 34 ); 30 35 } 31 36 32 - let Block = ({ 37 + let Block = async ({ 33 38 block, 34 39 did, 35 40 isList, ··· 69 74 /> 70 75 ))} 71 76 </ul> 77 + ); 78 + } 79 + case PubLeafletBlocksMath.isMain(b.block): { 80 + return <StaticMathBlock block={b.block} />; 81 + } 82 + case PubLeafletBlocksCode.isMain(b.block): { 83 + let html = await codeToHtml(b.block.plaintext, { 84 + lang: b.block.language || "plaintext", 85 + theme: b.block.syntaxHighlightingTheme || "github-light", 86 + }); 87 + return ( 88 + <div 89 + className="w-full min-h-[42px] rounded-md border-border-light outline-border-light selected-outline" 90 + dangerouslySetInnerHTML={{ __html: html }} 91 + /> 72 92 ); 73 93 } 74 94 case PubLeafletBlocksWebsite.isMain(b.block): {
+20
app/lish/[did]/[publication]/[rkey]/StaticMathBlock.tsx
··· 1 + import { PubLeafletBlocksMath } from "lexicons/api"; 2 + import Katex from "katex"; 3 + import "katex/dist/katex.min.css"; 4 + 5 + export const StaticMathBlock = ({ 6 + block, 7 + }: { 8 + block: PubLeafletBlocksMath.Main; 9 + }) => { 10 + const html = Katex.renderToString(block.tex, { 11 + displayMode: true, 12 + output: "html", 13 + throwOnError: false, 14 + }); 15 + return ( 16 + <div className="math-block"> 17 + <div dangerouslySetInnerHTML={{ __html: html }} /> 18 + </div> 19 + ); 20 + };
+20 -1
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
··· 1 1 import { 2 + PubLeafletBlocksCode, 2 3 PubLeafletBlocksHeader, 3 4 PubLeafletBlocksImage, 5 + PubLeafletBlocksMath, 4 6 PubLeafletBlocksText, 5 7 PubLeafletBlocksUnorderedList, 6 8 PubLeafletBlocksWebsite, ··· 9 11 } from "lexicons/api"; 10 12 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 13 import { TextBlock } from "./TextBlock"; 14 + import { StaticMathBlock } from "./StaticMathBlock"; 15 + import { codeToHtml } from "shiki"; 12 16 13 17 export function StaticPostContent({ 14 18 blocks, ··· 26 30 ); 27 31 } 28 32 29 - let Block = ({ 33 + let Block = async ({ 30 34 block, 31 35 did, 32 36 isList, ··· 38 42 let b = block; 39 43 40 44 switch (true) { 45 + case PubLeafletBlocksMath.isMain(b.block): { 46 + return <StaticMathBlock block={b.block} />; 47 + } 48 + case PubLeafletBlocksCode.isMain(b.block): { 49 + let html = await codeToHtml(b.block.plaintext, { 50 + lang: b.block.language || "plaintext", 51 + theme: b.block.syntaxHighlightingTheme || "github-light", 52 + }); 53 + return ( 54 + <div 55 + className="w-full min-h-[42px] rounded-md border-border-light outline-border-light selected-outline" 56 + dangerouslySetInnerHTML={{ __html: html }} 57 + /> 58 + ); 59 + } 41 60 case PubLeafletBlocksUnorderedList.isMain(b.block): { 42 61 return ( 43 62 <ul>
+37 -23
app/lish/[did]/[publication]/rss/route.ts
··· 18 18 params: Promise<{ publication: string; did: string }>; 19 19 }, 20 20 ) { 21 - let renderToStaticMarkup = await import("react-dom/server").then( 22 - (module) => module.renderToStaticMarkup, 21 + let renderToReadableStream = await import("react-dom/server").then( 22 + (module) => module.renderToReadableStream, 23 23 ); 24 24 let params = await props.params; 25 25 let { result: publication } = await get_publication_data.handler( ··· 46 46 }, 47 47 }); 48 48 49 - publication?.documents_in_publications.forEach((doc) => { 50 - if (!doc.documents) return; 51 - let record = doc.documents?.data as PubLeafletDocument.Record; 52 - let uri = new AtUri(doc.documents?.uri); 53 - let rkey = uri.rkey; 54 - if (!record) return; 55 - let firstPage = record.pages[0]; 56 - let blocks: PubLeafletPagesLinearDocument.Block[] = []; 57 - if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 58 - blocks = firstPage.blocks || []; 59 - } 60 - feed.addItem({ 61 - title: record.title, 62 - description: record.description, 63 - date: record.publishedAt ? new Date(record.publishedAt) : new Date(), 64 - id: `https://${pubRecord.base_path}/${rkey}`, 65 - link: `https://${pubRecord.base_path}/${rkey}`, 66 - content: renderToStaticMarkup( 49 + await Promise.all( 50 + publication?.documents_in_publications.map(async (doc) => { 51 + if (!doc.documents) return; 52 + let record = doc.documents?.data as PubLeafletDocument.Record; 53 + let uri = new AtUri(doc.documents?.uri); 54 + let rkey = uri.rkey; 55 + if (!record) return; 56 + let firstPage = record.pages[0]; 57 + let blocks: PubLeafletPagesLinearDocument.Block[] = []; 58 + if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 59 + blocks = firstPage.blocks || []; 60 + } 61 + let stream = await renderToReadableStream( 67 62 createElement(StaticPostContent, { blocks, did: uri.host }), 68 - ), 69 - }); 70 - }); 63 + ); 64 + const reader = stream.getReader(); 65 + const chunks = []; 66 + 67 + let done, value; 68 + while (!done) { 69 + ({ done, value } = await reader.read()); 70 + if (value) { 71 + chunks.push(new TextDecoder().decode(value)); 72 + } 73 + } 74 + 75 + feed.addItem({ 76 + title: record.title, 77 + description: record.description, 78 + date: record.publishedAt ? new Date(record.publishedAt) : new Date(), 79 + id: `https://${pubRecord.base_path}/${rkey}`, 80 + link: `https://${pubRecord.base_path}/${rkey}`, 81 + content: chunks.join(""), 82 + }); 83 + }), 84 + ); 71 85 return new Response(feed.rss2(), { 72 86 headers: { 73 87 "Content-Type": "text/xml",
+60
components/Blocks/BaseTextareaBlock.tsx
··· 1 + import { 2 + AsyncValueAutosizeTextarea, 3 + AutosizeTextareaProps, 4 + } from "components/utils/AutosizeTextarea"; 5 + import { BlockProps } from "./Block"; 6 + import { getCoordinatesInTextarea } from "src/utils/getCoordinatesInTextarea"; 7 + import { focusBlock } from "src/utils/focusBlock"; 8 + 9 + export function BaseTextareaBlock( 10 + props: AutosizeTextareaProps & { 11 + block: Pick<BlockProps, "previousBlock" | "nextBlock">; 12 + }, 13 + ) { 14 + let { block, ...passDownProps } = props; 15 + return ( 16 + <AsyncValueAutosizeTextarea 17 + {...passDownProps} 18 + onKeyDown={(e) => { 19 + if (e.key === "ArrowUp") { 20 + let selection = e.currentTarget.selectionStart; 21 + 22 + let lastLineBeforeCursor = e.currentTarget.value 23 + .slice(0, selection) 24 + .lastIndexOf("\n"); 25 + if (lastLineBeforeCursor !== -1) return; 26 + let block = props.block.previousBlock; 27 + let coord = getCoordinatesInTextarea(e.currentTarget, selection); 28 + if (block) { 29 + focusBlock(block, { 30 + left: coord.left + e.currentTarget.getBoundingClientRect().left, 31 + type: "bottom", 32 + }); 33 + return true; 34 + } 35 + } 36 + if (e.key === "ArrowDown") { 37 + let selection = e.currentTarget.selectionStart; 38 + 39 + let lastLine = e.currentTarget.value.lastIndexOf("\n"); 40 + let lastLineBeforeCursor = e.currentTarget.value 41 + .slice(0, selection) 42 + .lastIndexOf("\n"); 43 + if (lastLine !== lastLineBeforeCursor) return; 44 + e.preventDefault(); 45 + let block = props.block.nextBlock; 46 + 47 + let coord = getCoordinatesInTextarea(e.currentTarget, selection); 48 + console.log(coord); 49 + if (block) { 50 + focusBlock(block, { 51 + left: coord.left + e.currentTarget.getBoundingClientRect().left, 52 + type: "top", 53 + }); 54 + return true; 55 + } 56 + } 57 + }} 58 + /> 59 + ); 60 + }
+4
components/Blocks/Block.tsx
··· 26 26 import { CheckboxChecked } from "components/Icons/CheckboxChecked"; 27 27 import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; 28 28 import { LockTiny } from "components/Icons/LockTiny"; 29 + import { MathBlock } from "./MathBlock"; 30 + import { CodeBlock } from "./CodeBlock"; 29 31 30 32 export type Block = { 31 33 factID: string; ··· 168 170 BlockProps & { preview?: boolean } 169 171 >; 170 172 } = { 173 + code: CodeBlock, 174 + math: MathBlock, 171 175 card: PageLinkBlock, 172 176 text: TextBlock, 173 177 heading: TextBlock,
+20
components/Blocks/BlockCommands.tsx
··· 29 29 import { LinkSmall } from "components/Icons/LinkSmall"; 30 30 import { BlockRSVPSmall } from "components/Icons/BlockRSVPSmall"; 31 31 import { ListUnorderedSmall } from "components/Toolbar/ListToolbar"; 32 + import { BlockMathSmall } from "components/Icons/BlockMathSmall"; 33 + import { BlockCodeSmall } from "components/Icons/BlockCodeSmall"; 32 34 33 35 type Props = { 34 36 parent: string; ··· 265 267 hiddenInPublication: true, 266 268 onSelect: async (rep, props) => { 267 269 createBlockWithType(rep, props, "bluesky-post"); 270 + }, 271 + }, 272 + { 273 + name: "Math", 274 + icon: <BlockMathSmall />, 275 + type: "block", 276 + hiddenInPublication: false, 277 + onSelect: async (rep, props) => { 278 + createBlockWithType(rep, props, "math"); 279 + }, 280 + }, 281 + { 282 + name: "Code", 283 + icon: <BlockCodeSmall />, 284 + type: "block", 285 + hiddenInPublication: false, 286 + onSelect: async (rep, props) => { 287 + createBlockWithType(rep, props, "code"); 268 288 }, 269 289 }, 270 290
+157
components/Blocks/CodeBlock.tsx
··· 1 + import { 2 + BundledLanguage, 3 + bundledLanguagesInfo, 4 + bundledThemesInfo, 5 + codeToHtml, 6 + } from "shiki"; 7 + import { useEntity, useReplicache } from "src/replicache"; 8 + import "katex/dist/katex.min.css"; 9 + import { BlockProps } from "./Block"; 10 + import { useCallback, useLayoutEffect, useMemo, useState } from "react"; 11 + import { useUIState } from "src/useUIState"; 12 + import { BaseTextareaBlock } from "./BaseTextareaBlock"; 13 + import { useEntitySetContext } from "components/EntitySetProvider"; 14 + import { flushSync } from "react-dom"; 15 + import { elementId } from "src/utils/elementId"; 16 + 17 + export function CodeBlock(props: BlockProps) { 18 + let { rep, rootEntity } = useReplicache(); 19 + let content = useEntity(props.entityID, "block/code"); 20 + let lang = 21 + useEntity(props.entityID, "block/code-language")?.data.value || "plaintext"; 22 + 23 + let theme = 24 + useEntity(rootEntity, "theme/code-theme")?.data.value || "github-light"; 25 + let focusedBlock = useUIState( 26 + (s) => s.focusedEntity?.entityID === props.entityID, 27 + ); 28 + let { permissions } = useEntitySetContext(); 29 + const [html, setHTML] = useState<string | null>(null); 30 + 31 + useLayoutEffect(() => { 32 + if (!content) return; 33 + void codeToHtml(content.data.value, { 34 + lang, 35 + theme, 36 + structure: "classic", 37 + }).then((h) => { 38 + setHTML(h.replaceAll("<br>", "\n")); 39 + }); 40 + }, [content, lang, theme]); 41 + 42 + const onClick = useCallback((e: React.MouseEvent<HTMLElement>) => { 43 + let selection = window.getSelection(); 44 + if (!selection || selection.rangeCount === 0) return; 45 + let range = selection.getRangeAt(0); 46 + if (!range) return; 47 + let length = range.toString().length; 48 + range.setStart(e.currentTarget, 0); 49 + let end = range.toString().length; 50 + let start = end - length; 51 + 52 + flushSync(() => { 53 + useUIState.getState().setSelectedBlock(props); 54 + useUIState.getState().setFocusedBlock({ 55 + entityType: "block", 56 + entityID: props.value, 57 + parent: props.parent, 58 + }); 59 + }); 60 + let el = document.getElementById( 61 + elementId.block(props.entityID).input, 62 + ) as HTMLTextAreaElement; 63 + if (!el) return; 64 + el.focus(); 65 + el.setSelectionRange(start, end); 66 + }, []); 67 + return ( 68 + <div className="codeBlock w-full flex flex-col rounded-md gap-0.5 "> 69 + {permissions.write && ( 70 + <div className="text-sm text-tertiary flex justify-between"> 71 + <div className="flex gap-1"> 72 + Theme:{" "} 73 + <select 74 + className="codeBlockLang text-left bg-transparent pr-1" 75 + onClick={(e) => { 76 + e.preventDefault(); 77 + e.stopPropagation(); 78 + }} 79 + value={theme} 80 + onChange={async (e) => { 81 + await rep?.mutate.assertFact({ 82 + attribute: "theme/code-theme", 83 + entity: rootEntity, 84 + data: { type: "string", value: e.target.value }, 85 + }); 86 + }} 87 + > 88 + {bundledThemesInfo.map((t) => ( 89 + <option key={t.id} value={t.id}> 90 + {t.displayName} 91 + </option> 92 + ))} 93 + </select> 94 + </div> 95 + <select 96 + className="codeBlockLang text-right bg-transparent pr-1" 97 + onClick={(e) => { 98 + e.preventDefault(); 99 + e.stopPropagation(); 100 + }} 101 + value={lang} 102 + onChange={async (e) => { 103 + await rep?.mutate.assertFact({ 104 + attribute: "block/code-language", 105 + entity: props.entityID, 106 + data: { type: "string", value: e.target.value }, 107 + }); 108 + }} 109 + > 110 + <option value="plaintext">Plaintext</option> 111 + {bundledLanguagesInfo.map((l) => ( 112 + <option key={l.id} value={l.id}> 113 + {l.name} 114 + </option> 115 + ))} 116 + </select> 117 + </div> 118 + )} 119 + <div className="w-full min-h-[42px] rounded-md border-border-light outline-border-light selected-outline"> 120 + {focusedBlock && permissions.write ? ( 121 + <BaseTextareaBlock 122 + data-editable-block 123 + data-entityid={props.entityID} 124 + id={elementId.block(props.entityID).input} 125 + block={props} 126 + className="codeBlockEditor !whitespace-nowrap !overflow-auto font-mono p-2" 127 + value={content?.data.value} 128 + onChange={async (e) => { 129 + // Update the entity with the new value 130 + await rep?.mutate.assertFact({ 131 + attribute: "block/code", 132 + entity: props.entityID, 133 + data: { type: "string", value: e.target.value }, 134 + }); 135 + }} 136 + /> 137 + ) : !html ? ( 138 + <pre 139 + onClick={onClick} 140 + onMouseDown={(e) => e.stopPropagation()} 141 + className="codeBlockRendered !overflow-auto font-mono p-2 w-full h-full" 142 + > 143 + {content?.data.value} 144 + </pre> 145 + ) : ( 146 + <div 147 + onMouseDown={(e) => e.stopPropagation()} 148 + onClick={onClick} 149 + data-lang={lang} 150 + className="contents" 151 + dangerouslySetInnerHTML={{ __html: html || "" }} 152 + /> 153 + )} 154 + </div> 155 + </div> 156 + ); 157 + }
+60
components/Blocks/MathBlock.tsx
··· 1 + import { useEntity, useReplicache } from "src/replicache"; 2 + import "katex/dist/katex.min.css"; 3 + import { BlockProps } from "./Block"; 4 + import Katex from "katex"; 5 + import { useMemo } from "react"; 6 + import { useUIState } from "src/useUIState"; 7 + import { theme } from "tailwind.config"; 8 + import { BaseTextareaBlock } from "./BaseTextareaBlock"; 9 + import { elementId } from "src/utils/elementId"; 10 + 11 + export function MathBlock(props: BlockProps) { 12 + let content = useEntity(props.entityID, "block/math"); 13 + let focusedBlock = useUIState( 14 + (s) => s.focusedEntity?.entityID === props.entityID, 15 + ); 16 + let { rep } = useReplicache(); 17 + const { html, error } = useMemo(() => { 18 + try { 19 + const html = Katex.renderToString(content?.data.value || "", { 20 + displayMode: true, 21 + throwOnError: false, 22 + errorColor: theme.colors["accent-contrast"], 23 + }); 24 + 25 + return { html, error: undefined }; 26 + } catch (error) { 27 + if (error instanceof Katex.ParseError || error instanceof TypeError) { 28 + return { error }; 29 + } 30 + 31 + throw error; 32 + } 33 + }, [content?.data.value]); 34 + return focusedBlock ? ( 35 + <BaseTextareaBlock 36 + id={elementId.block(props.entityID).input} 37 + block={props} 38 + className="bg-border-light rounded-md p-2 w-full min-h-[48px] whitespace-nowrap !overflow-auto border-border-light outline-border-light selected-outline" 39 + placeholder="write some Tex here..." 40 + value={content?.data.value} 41 + onChange={async (e) => { 42 + // Update the entity with the new value 43 + await rep?.mutate.assertFact({ 44 + attribute: "block/math", 45 + entity: props.entityID, 46 + data: { type: "string", value: e.target.value }, 47 + }); 48 + }} 49 + /> 50 + ) : html && content?.data.value ? ( 51 + <div 52 + className="text-lg min-h-[66px] w-full border border-transparent" 53 + dangerouslySetInnerHTML={{ __html: html }} 54 + /> 55 + ) : ( 56 + <div className="text-tertiary italic rounded-md p-2 w-full min-h-16"> 57 + write some Tex here... 58 + </div> 59 + ); 60 + }
+16
components/Blocks/TextBlock/inputRules.ts
··· 10 10 import { focusBlock } from "src/utils/focusBlock"; 11 11 import { schema } from "./schema"; 12 12 import { useUIState } from "src/useUIState"; 13 + import { flushSync } from "react-dom"; 13 14 export const inputrules = ( 14 15 propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 15 16 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, ··· 84 85 .removeStoredMark(schema.marks.em); 85 86 return tr; 86 87 } 88 + return null; 89 + }), 90 + 91 + // Code Block 92 + new InputRule(/^```\s$/, (state, match) => { 93 + flushSync(() => 94 + repRef.current?.mutate.assertFact({ 95 + entity: propsRef.current.entityID, 96 + attribute: "block/type", 97 + data: { type: "block-type-union", value: "code" }, 98 + }), 99 + ); 100 + setTimeout(() => { 101 + focusBlock({ ...propsRef.current, type: "code" }, { type: "start" }); 102 + }, 20); 87 103 return null; 88 104 }), 89 105
+51 -1
components/Blocks/TextBlock/useHandlePaste.ts
··· 208 208 type = "text"; 209 209 break; 210 210 } 211 + case "PRE": { 212 + type = "code"; 213 + break; 214 + } 211 215 case "P": { 212 216 type = "text"; 213 217 break; ··· 312 316 } 313 317 } 314 318 } 319 + if (child.tagName === "PRE") { 320 + let lang = child.getAttribute("data-language") || "plaintext"; 321 + if (child.firstElementChild && child.firstElementChild.className) { 322 + let className = child.firstElementChild.className; 323 + let match = className.match(/language-(\w+)/); 324 + if (match) { 325 + lang = match[1]; 326 + } 327 + } 328 + if (child.textContent) { 329 + rep.mutate.assertFact([ 330 + { 331 + entity: entityID, 332 + attribute: "block/type", 333 + data: { type: "block-type-union", value: "code" }, 334 + }, 335 + { 336 + entity: entityID, 337 + attribute: "block/code-language", 338 + data: { type: "string", value: lang }, 339 + }, 340 + { 341 + entity: entityID, 342 + attribute: "block/code", 343 + data: { type: "string", value: child.textContent }, 344 + }, 345 + ]); 346 + } 347 + } 315 348 if (child.tagName === "IMG") { 316 349 let src = child.getAttribute("src"); 317 350 if (src) { ··· 325 358 }); 326 359 }); 327 360 } 361 + } 362 + if (child.tagName === "DIV" && child.getAttribute("data-tex")) { 363 + let tex = child.getAttribute("data-tex"); 364 + rep.mutate.assertFact([ 365 + { 366 + entity: entityID, 367 + attribute: "block/type", 368 + data: { type: "block-type-union", value: "math" }, 369 + }, 370 + { 371 + entity: entityID, 372 + attribute: "block/math", 373 + data: { type: "string", value: tex || "" }, 374 + }, 375 + ]); 328 376 } 329 377 330 378 if (child.tagName === "DIV" && child.getAttribute("data-entityid")) { ··· 503 551 if ( 504 552 [ 505 553 "P", 554 + "PRE", 506 555 "H1", 507 556 "H2", 508 557 "H3", ··· 515 564 "A", 516 565 "SPAN", 517 566 ].includes(elementNode.tagName) || 518 - elementNode.getAttribute("data-entityid") 567 + elementNode.getAttribute("data-entityid") || 568 + elementNode.getAttribute("data-tex") 519 569 ) { 520 570 htmlBlocks.push(elementNode); 521 571 } else {
+1
components/Blocks/useBlockKeyboardHandlers.ts
··· 53 53 (el.tagName === "LABEL" || 54 54 el.tagName === "INPUT" || 55 55 el.tagName === "TEXTAREA" || 56 + el.tagName === "SELECT" || 56 57 el.contentEditable === "true") && 57 58 !isTextBlock[props.type] 58 59 ) {
+3
components/Blocks/useBlockMouseHandlers.ts
··· 18 18 (e: MouseEvent) => { 19 19 if ((e.target as Element).getAttribute("data-draggable")) return; 20 20 if ((e.target as Element).tagName === "BUTTON") return; 21 + if ((e.target as Element).tagName === "SELECT") return; 22 + if ((e.target as Element).tagName === "OPTION") return; 21 23 if (isMobile) return; 22 24 if (!entity_set.permissions.write) return; 23 25 useSelectingMouse.setState({ start: props.value }); ··· 30 32 e.preventDefault(); 31 33 useUIState.getState().addBlockToSelection(props); 32 34 } else { 35 + if (e.isDefaultPrevented()) return; 33 36 useUIState.getState().setFocusedBlock({ 34 37 entityType: "block", 35 38 entityID: props.value,
+19
components/Icons/BlockCodeSmall.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const BlockCodeSmall = (props: Props) => { 4 + return ( 5 + <svg 6 + width="24" 7 + height="24" 8 + viewBox="0 0 24 24" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M13.2324 4.77635C13.3702 4.31348 13.8573 4.04915 14.3203 4.1865C14.7832 4.32411 15.0473 4.81137 14.9102 5.2744L10.7686 19.2236C10.6309 19.6865 10.1437 19.9498 9.68066 19.8125C9.21745 19.6749 8.95327 19.1878 9.09082 18.7246L13.2324 4.77635ZM16.4365 8.07615C16.6974 7.70589 17.1936 7.60016 17.5801 7.81736L22.5107 11.2851C22.7434 11.449 22.8818 11.7154 22.8818 12C22.8818 12.2845 22.7443 12.5519 22.5117 12.7158L17.6562 16.1357C17.2612 16.4138 16.7158 16.3187 16.4375 15.9238C16.1594 15.5288 16.2536 14.9834 16.6484 14.7051L20.4873 12L16.5781 9.24022C16.2433 8.94956 16.1759 8.44649 16.4365 8.07615ZM6.34375 7.86424C6.7387 7.5862 7.28419 7.68141 7.5625 8.07615C7.84075 8.47115 7.74645 9.01655 7.35156 9.2949L3.5127 12L7.42188 14.7597C7.75678 15.0503 7.82408 15.5534 7.56348 15.9238C7.3026 16.2941 6.80642 16.4 6.41992 16.1826L1.48926 12.7148C1.25672 12.5509 1.11819 12.2845 1.11816 12C1.11826 11.7155 1.25577 11.448 1.48828 11.2842L6.34375 7.86424Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+19
components/Icons/BlockMathSmall.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const BlockMathSmall = (props: Props) => { 4 + return ( 5 + <svg 6 + width="24" 7 + height="24" 8 + viewBox="0 0 24 24" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M20.0457 3.03378C20.376 2.7842 20.8457 2.8492 21.0955 3.17928C21.6323 3.88971 22.2815 5.32017 22.699 7.04745C23.1207 8.79244 23.3277 10.9278 22.907 13.0582C22.4871 15.1837 21.5327 17.0069 20.5037 18.3756C19.4921 19.721 18.347 20.7072 17.4832 21.0767C17.1024 21.2394 16.6607 21.0629 16.4978 20.6822C16.3454 20.3253 16.4916 19.9151 16.824 19.731C17.8346 19.2254 18.6323 18.3681 19.3045 17.4742C20.2234 16.2521 21.0677 14.634 21.4363 12.7682C21.8039 10.9069 21.6271 8.99898 21.241 7.40096C20.8505 5.78529 20.2671 4.57045 19.8992 4.08358C19.6497 3.75316 19.7154 3.28346 20.0457 3.03378ZM7.02322 2.94003C7.36769 2.77966 7.78445 2.90248 7.98318 3.23593C8.1949 3.5917 8.07814 4.05229 7.72244 4.26425C7.3224 4.53951 6.97328 4.87074 6.64334 5.22714C6.18529 5.72197 5.67359 6.36158 5.17459 7.10214C4.17164 8.59069 3.25199 10.4405 2.89041 12.3072C2.38971 14.8928 2.87516 17.9083 4.67556 19.9166C4.91444 20.2124 4.89491 20.6473 4.61892 20.9205C4.34297 21.1933 3.90826 21.208 3.61502 20.9664C1.32284 18.8713 0.86522 14.8753 1.41775 12.0221C1.82983 9.8945 2.85796 7.85634 3.93142 6.26327C4.4705 5.46327 5.02886 4.76272 5.54275 4.2076C5.97315 3.74269 6.45323 3.23498 7.02322 2.94003ZM13.6101 9.53964C14.2613 8.93207 15.2373 8.72054 16.0301 8.82479C16.4365 8.87834 16.8678 9.02295 17.2107 9.31405C17.2107 9.31405 17.4185 9.44658 17.6414 9.87264C17.8642 10.299 18.0163 11.3361 17.1726 12.1021C16.5342 12.6819 15.3168 12.5718 15.2254 11.4664C15.1575 10.6436 15.7689 10.5461 15.7791 10.5445C15.901 10.4843 16.1162 10.2308 15.9031 10.2027C15.4562 10.1439 14.89 10.396 14.6326 10.6363C14.0975 11.136 13.3817 12.1481 12.9724 13.1724C12.7691 13.6816 12.6624 14.1434 12.6638 14.5123C12.6654 14.8706 12.7652 15.0803 12.9119 15.2125C13.2749 15.5395 13.6579 15.6457 14.0623 15.6109C14.4914 15.5739 14.9812 15.3728 15.4842 15.0142C15.8213 14.7738 16.2905 14.8521 16.531 15.189C16.7711 15.5262 16.6932 15.9955 16.3562 16.2359C15.7122 16.6951 14.9689 17.0379 14.1912 17.1051C13.3889 17.1742 12.5905 16.9436 11.907 16.3277C11.5457 16.0021 11.3388 15.6008 11.239 15.1822C10.8972 15.6384 10.5507 16.0322 10.2478 16.315C9.67058 16.854 8.77915 17.0924 8.06619 17.0924C7.70236 17.0923 7.29507 17.0316 6.949 16.8433C6.60986 16.6588 6.50762 16.4793 6.50369 16.4723C6.20573 16.1484 5.83569 15.2012 6.40017 14.3238C6.82728 13.66 8.00558 13.4446 8.27127 14.4244C8.49573 15.2532 7.24003 15.4531 7.9158 15.5855L8.06717 15.5933C8.52785 15.5932 9.00624 15.4227 9.22439 15.2193C9.77288 14.7071 10.5896 13.6314 11.0808 12.5631C11.3265 12.0286 11.4625 11.5543 11.4773 11.19C11.4914 10.8428 11.3976 10.6868 11.2801 10.5963C10.921 10.3206 9.89525 10.2055 9.03787 11.0269L8.97927 11.0777C8.67978 11.3113 8.24689 11.2844 7.9783 11.0045C7.69171 10.7054 7.7017 10.2305 8.00076 9.94393L8.24978 9.72421C9.52897 8.68979 11.2168 8.65653 12.1931 9.40585C12.5421 9.67382 12.7536 10.0121 12.8679 10.3726C13.1174 10.0477 13.3711 9.76281 13.6101 9.53964Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+5 -1
components/SelectionManager.tsx
··· 514 514 savedSelection.current = null; 515 515 if ( 516 516 initialContentEditableParent.current && 517 + !(e.target as Element).getAttribute("data-draggable") && 517 518 getContentEditableParent(e.target as Node) !== 518 519 initialContentEditableParent.current 519 520 ) { ··· 617 618 function getContentEditableParent(e: Node | null): Node | null { 618 619 let element: Node | null = e; 619 620 while (element && element !== document) { 620 - if ((element as HTMLElement).contentEditable === "true") { 621 + if ( 622 + (element as HTMLElement).contentEditable === "true" || 623 + (element as HTMLElement).getAttribute("data-editable-block") 624 + ) { 621 625 return element; 622 626 } 623 627 element = element.parentNode;
+24 -23
components/utils/AutosizeTextarea.tsx
··· 7 7 } from "react"; 8 8 import styles from "./textarea-styles.module.css"; 9 9 10 - type Props = React.DetailedHTMLProps< 10 + export type AutosizeTextareaProps = React.DetailedHTMLProps< 11 11 React.TextareaHTMLAttributes<HTMLTextAreaElement>, 12 12 HTMLTextAreaElement 13 13 >; 14 - export const AutosizeTextarea = forwardRef<HTMLTextAreaElement, Props>( 15 - (props: Props, ref) => { 16 - let textarea = useRef<HTMLTextAreaElement | null>(null); 17 - useImperativeHandle(ref, () => textarea.current as HTMLTextAreaElement); 14 + export const AutosizeTextarea = forwardRef< 15 + HTMLTextAreaElement, 16 + AutosizeTextareaProps 17 + >((props: AutosizeTextareaProps, ref) => { 18 + let textarea = useRef<HTMLTextAreaElement | null>(null); 19 + useImperativeHandle(ref, () => textarea.current as HTMLTextAreaElement); 18 20 19 - return ( 20 - <div 21 - className={`${styles["grow-wrap"]} ${props.className} `} 22 - data-replicated-value={props.value} 23 - style={props.style} 24 - > 25 - <textarea 26 - rows={1} 27 - {...props} 28 - ref={textarea} 29 - className="placeholder:text-tertiary bg-transparent" 30 - /> 31 - </div> 32 - ); 33 - }, 34 - ); 21 + return ( 22 + <div 23 + className={`${styles["grow-wrap"]} ${props.className} `} 24 + data-replicated-value={props.value} 25 + style={props.style} 26 + > 27 + <textarea 28 + rows={1} 29 + {...props} 30 + ref={textarea} 31 + className={`placeholder:text-tertiary bg-transparent ${props.className}`} 32 + /> 33 + </div> 34 + ); 35 + }); 35 36 36 37 export const AsyncValueAutosizeTextarea = forwardRef< 37 38 HTMLTextAreaElement, 38 - Props 39 - >((props: Props, ref) => { 39 + AutosizeTextareaProps 40 + >((props: AutosizeTextareaProps, ref) => { 40 41 let [intermediateState, setIntermediateState] = useState( 41 42 props.value as string, 42 43 );
+1 -1
components/utils/textarea-styles.module.css
··· 11 11 content: attr(data-replicated-value) " "; 12 12 13 13 /* This is how textarea text behaves */ 14 - white-space: pre-wrap; 14 + white-space: pre; 15 15 16 16 /* Hidden from view, clicks, and screen readers */ 17 17 visibility: hidden;
+4
lexicons/api/index.ts
··· 7 7 import { OmitKey, Un$Typed } from './util' 8 8 import * as PubLeafletDocument from './types/pub/leaflet/document' 9 9 import * as PubLeafletPublication from './types/pub/leaflet/publication' 10 + import * as PubLeafletBlocksCode from './types/pub/leaflet/blocks/code' 10 11 import * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header' 11 12 import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 13 + import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 12 14 import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 13 15 import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 14 16 import * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' ··· 34 36 35 37 export * as PubLeafletDocument from './types/pub/leaflet/document' 36 38 export * as PubLeafletPublication from './types/pub/leaflet/publication' 39 + export * as PubLeafletBlocksCode from './types/pub/leaflet/blocks/code' 37 40 export * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header' 38 41 export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 42 + export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 39 43 export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 40 44 export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 41 45 export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website'
+40
lexicons/api/lexicons.ts
··· 161 161 }, 162 162 }, 163 163 }, 164 + PubLeafletBlocksCode: { 165 + lexicon: 1, 166 + id: 'pub.leaflet.blocks.code', 167 + defs: { 168 + main: { 169 + type: 'object', 170 + required: ['plaintext'], 171 + properties: { 172 + plaintext: { 173 + type: 'string', 174 + }, 175 + language: { 176 + type: 'string', 177 + }, 178 + syntaxHighlightingTheme: { 179 + type: 'string', 180 + }, 181 + }, 182 + }, 183 + }, 184 + }, 164 185 PubLeafletBlocksHeader: { 165 186 lexicon: 1, 166 187 id: 'pub.leaflet.blocks.header', ··· 221 242 }, 222 243 height: { 223 244 type: 'integer', 245 + }, 246 + }, 247 + }, 248 + }, 249 + }, 250 + PubLeafletBlocksMath: { 251 + lexicon: 1, 252 + id: 'pub.leaflet.blocks.math', 253 + defs: { 254 + main: { 255 + type: 'object', 256 + required: ['tex'], 257 + properties: { 258 + tex: { 259 + type: 'string', 224 260 }, 225 261 }, 226 262 }, ··· 364 400 'lex:pub.leaflet.blocks.image', 365 401 'lex:pub.leaflet.blocks.unorderedList', 366 402 'lex:pub.leaflet.blocks.website', 403 + 'lex:pub.leaflet.blocks.math', 404 + 'lex:pub.leaflet.blocks.code', 367 405 ], 368 406 }, 369 407 alignment: { ··· 1592 1630 export const ids = { 1593 1631 PubLeafletDocument: 'pub.leaflet.document', 1594 1632 PubLeafletPublication: 'pub.leaflet.publication', 1633 + PubLeafletBlocksCode: 'pub.leaflet.blocks.code', 1595 1634 PubLeafletBlocksHeader: 'pub.leaflet.blocks.header', 1596 1635 PubLeafletBlocksImage: 'pub.leaflet.blocks.image', 1636 + PubLeafletBlocksMath: 'pub.leaflet.blocks.math', 1597 1637 PubLeafletBlocksText: 'pub.leaflet.blocks.text', 1598 1638 PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', 1599 1639 PubLeafletBlocksWebsite: 'pub.leaflet.blocks.website',
+28
lexicons/api/types/pub/leaflet/blocks/code.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util' 8 + 9 + const is$typed = _is$typed, 10 + validate = _validate 11 + const id = 'pub.leaflet.blocks.code' 12 + 13 + export interface Main { 14 + $type?: 'pub.leaflet.blocks.code' 15 + plaintext: string 16 + language?: string 17 + syntaxHighlightingTheme?: string 18 + } 19 + 20 + const hashMain = 'main' 21 + 22 + export function isMain<V>(v: V) { 23 + return is$typed(v, id, hashMain) 24 + } 25 + 26 + export function validateMain<V>(v: V) { 27 + return validate<Main & V>(v, id, hashMain) 28 + }
+26
lexicons/api/types/pub/leaflet/blocks/math.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util' 8 + 9 + const is$typed = _is$typed, 10 + validate = _validate 11 + const id = 'pub.leaflet.blocks.math' 12 + 13 + export interface Main { 14 + $type?: 'pub.leaflet.blocks.math' 15 + tex: string 16 + } 17 + 18 + const hashMain = 'main' 19 + 20 + export function isMain<V>(v: V) { 21 + return is$typed(v, id, hashMain) 22 + } 23 + 24 + export function validateMain<V>(v: V) { 25 + return validate<Main & V>(v, id, hashMain) 26 + }
+4
lexicons/api/types/pub/leaflet/pages/linearDocument.ts
··· 10 10 import type * as PubLeafletBlocksImage from '../blocks/image' 11 11 import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList' 12 12 import type * as PubLeafletBlocksWebsite from '../blocks/website' 13 + import type * as PubLeafletBlocksMath from '../blocks/math' 14 + import type * as PubLeafletBlocksCode from '../blocks/code' 13 15 14 16 const is$typed = _is$typed, 15 17 validate = _validate ··· 38 40 | $Typed<PubLeafletBlocksImage.Main> 39 41 | $Typed<PubLeafletBlocksUnorderedList.Main> 40 42 | $Typed<PubLeafletBlocksWebsite.Main> 43 + | $Typed<PubLeafletBlocksMath.Main> 44 + | $Typed<PubLeafletBlocksCode.Main> 41 45 | { $type: string } 42 46 alignment?: 43 47 | 'lex:pub.leaflet.pages.linearDocument#textAlignLeft'
+23
lexicons/pub/leaflet/blocks/code.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.blocks.code", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": [ 8 + "plaintext" 9 + ], 10 + "properties": { 11 + "plaintext": { 12 + "type": "string" 13 + }, 14 + "language": { 15 + "type": "string" 16 + }, 17 + "syntaxHighlightingTheme": { 18 + "type": "string" 19 + } 20 + } 21 + } 22 + } 23 + }
+17
lexicons/pub/leaflet/blocks/math.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.blocks.math", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": [ 8 + "tex" 9 + ], 10 + "properties": { 11 + "tex": { 12 + "type": "string" 13 + } 14 + } 15 + } 16 + } 17 + }
+3 -1
lexicons/pub/leaflet/pages/linearDocument.json
··· 27 27 "pub.leaflet.blocks.header", 28 28 "pub.leaflet.blocks.image", 29 29 "pub.leaflet.blocks.unorderedList", 30 - "pub.leaflet.blocks.website" 30 + "pub.leaflet.blocks.website", 31 + "pub.leaflet.blocks.math", 32 + "pub.leaflet.blocks.code" 31 33 ] 32 34 }, 33 35 "alignment": {
+31
lexicons/src/blocks.ts
··· 18 18 }, 19 19 }, 20 20 }; 21 + export const PubLeafletBlocksCode: LexiconDoc = { 22 + lexicon: 1, 23 + id: "pub.leaflet.blocks.code", 24 + defs: { 25 + main: { 26 + type: "object", 27 + required: ["plaintext"], 28 + properties: { 29 + plaintext: { type: "string" }, 30 + language: { type: "string" }, 31 + syntaxHighlightingTheme: { type: "string" }, 32 + }, 33 + }, 34 + }, 35 + }; 36 + 37 + export const PubLeafletBlocksMath: LexiconDoc = { 38 + lexicon: 1, 39 + id: "pub.leaflet.blocks.math", 40 + defs: { 41 + main: { 42 + type: "object", 43 + required: ["tex"], 44 + properties: { 45 + tex: { type: "string" }, 46 + }, 47 + }, 48 + }, 49 + }; 21 50 22 51 export const PubLeafletBlocksWebsite: LexiconDoc = { 23 52 lexicon: 1, ··· 119 148 PubLeafletBlocksImage, 120 149 PubLeafletBlocksUnorderedList, 121 150 PubLeafletBlocksWebsite, 151 + PubLeafletBlocksMath, 152 + PubLeafletBlocksCode, 122 153 ]; 123 154 export const BlockUnion: LexRefUnion = { 124 155 type: "union",
+164 -51
package-lock.json
··· 43 43 "fractional-indexing": "^3.2.0", 44 44 "hono": "^4.7.11", 45 45 "ioredis": "^5.6.1", 46 + "katex": "^0.16.22", 46 47 "linkifyjs": "^4.2.0", 47 48 "multiformats": "^13.3.2", 48 49 "next": "15.3.2", ··· 68 69 "remark-stringify": "^11.0.0", 69 70 "replicache": "^14.2.2", 70 71 "sharp": "^0.34.2", 72 + "shiki": "^3.8.1", 71 73 "swr": "^2.3.3", 72 74 "thumbhash": "^0.1.1", 73 75 "twilio": "^5.3.7", ··· 82 84 "@atproto/lex-cli": "^0.6.1", 83 85 "@atproto/lexicon": "^0.4.7", 84 86 "@cloudflare/workers-types": "^4.20240512.0", 87 + "@types/katex": "^0.16.7", 85 88 "@types/node": "^22.15.17", 86 89 "@types/react": "19.1.3", 87 90 "@types/react-dom": "19.1.3", ··· 96 99 "supabase": "^1.187.3", 97 100 "tailwindcss": "^3.4.3", 98 101 "tsx": "^4.19.3", 99 - "typescript": "^5.5.3", 102 + "typescript": "^5.8.3", 100 103 "wrangler": "^3.56.0" 101 104 } 102 105 }, ··· 5671 5674 "integrity": "sha512-qC/xYId4NMebE6w/V33Fh9gWxLgURiNYgVNObbJl2LZv0GUUItCcCqC5axQSwRaAgaxl2mELq1rMzlswaQ0Zxg==", 5672 5675 "dev": true 5673 5676 }, 5677 + "node_modules/@shikijs/core": { 5678 + "version": "3.8.1", 5679 + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.8.1.tgz", 5680 + "integrity": "sha512-uTSXzUBQ/IgFcUa6gmGShCHr4tMdR3pxUiiWKDm8pd42UKJdYhkAYsAmHX5mTwybQ5VyGDgTjW4qKSsRvGSang==", 5681 + "dependencies": { 5682 + "@shikijs/types": "3.8.1", 5683 + "@shikijs/vscode-textmate": "^10.0.2", 5684 + "@types/hast": "^3.0.4", 5685 + "hast-util-to-html": "^9.0.5" 5686 + } 5687 + }, 5688 + "node_modules/@shikijs/engine-javascript": { 5689 + "version": "3.8.1", 5690 + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.8.1.tgz", 5691 + "integrity": "sha512-rZRp3BM1llrHkuBPAdYAzjlF7OqlM0rm/7EWASeCcY7cRYZIrOnGIHE9qsLz5TCjGefxBFnwgIECzBs2vmOyKA==", 5692 + "dependencies": { 5693 + "@shikijs/types": "3.8.1", 5694 + "@shikijs/vscode-textmate": "^10.0.2", 5695 + "oniguruma-to-es": "^4.3.3" 5696 + } 5697 + }, 5698 + "node_modules/@shikijs/engine-oniguruma": { 5699 + "version": "3.8.1", 5700 + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.8.1.tgz", 5701 + "integrity": "sha512-KGQJZHlNY7c656qPFEQpIoqOuC4LrxjyNndRdzk5WKB/Ie87+NJCF1xo9KkOUxwxylk7rT6nhlZyTGTC4fCe1g==", 5702 + "dependencies": { 5703 + "@shikijs/types": "3.8.1", 5704 + "@shikijs/vscode-textmate": "^10.0.2" 5705 + } 5706 + }, 5707 + "node_modules/@shikijs/langs": { 5708 + "version": "3.8.1", 5709 + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.8.1.tgz", 5710 + "integrity": "sha512-TjOFg2Wp1w07oKnXjs0AUMb4kJvujML+fJ1C5cmEj45lhjbUXtziT1x2bPQb9Db6kmPhkG5NI2tgYW1/DzhUuQ==", 5711 + "dependencies": { 5712 + "@shikijs/types": "3.8.1" 5713 + } 5714 + }, 5715 + "node_modules/@shikijs/themes": { 5716 + "version": "3.8.1", 5717 + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.8.1.tgz", 5718 + "integrity": "sha512-Vu3t3BBLifc0GB0UPg2Pox1naTemrrvyZv2lkiSw3QayVV60me1ujFQwPZGgUTmwXl1yhCPW8Lieesm0CYruLQ==", 5719 + "dependencies": { 5720 + "@shikijs/types": "3.8.1" 5721 + } 5722 + }, 5723 + "node_modules/@shikijs/types": { 5724 + "version": "3.8.1", 5725 + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.8.1.tgz", 5726 + "integrity": "sha512-5C39Q8/8r1I26suLh+5TPk1DTrbY/kn3IdWA5HdizR0FhlhD05zx5nKCqhzSfDHH3p4S0ZefxWd77DLV+8FhGg==", 5727 + "dependencies": { 5728 + "@shikijs/vscode-textmate": "^10.0.2", 5729 + "@types/hast": "^3.0.4" 5730 + } 5731 + }, 5732 + "node_modules/@shikijs/vscode-textmate": { 5733 + "version": "10.0.2", 5734 + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", 5735 + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==" 5736 + }, 5674 5737 "node_modules/@supabase/auth-js": { 5675 5738 "version": "2.64.2", 5676 5739 "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.64.2.tgz", ··· 5900 5963 "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", 5901 5964 "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", 5902 5965 "dev": true 5966 + }, 5967 + "node_modules/@types/katex": { 5968 + "version": "0.16.7", 5969 + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", 5970 + "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", 5971 + "dev": true, 5972 + "license": "MIT" 5903 5973 }, 5904 5974 "node_modules/@types/linkify-it": { 5905 5975 "version": "5.0.0", ··· 10210 10280 "url": "https://opencollective.com/unified" 10211 10281 } 10212 10282 }, 10213 - "node_modules/hast-util-raw": { 10214 - "version": "9.0.4", 10215 - "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.0.4.tgz", 10216 - "integrity": "sha512-LHE65TD2YiNsHD3YuXcKPHXPLuYh/gjp12mOfU8jxSrm1f/yJpsb0F/KKljS6U9LJoP0Ux+tCe8iJ2AsPzTdgA==", 10217 - "dependencies": { 10218 - "@types/hast": "^3.0.0", 10219 - "@types/unist": "^3.0.0", 10220 - "@ungap/structured-clone": "^1.0.0", 10221 - "hast-util-from-parse5": "^8.0.0", 10222 - "hast-util-to-parse5": "^8.0.0", 10223 - "html-void-elements": "^3.0.0", 10224 - "mdast-util-to-hast": "^13.0.0", 10225 - "parse5": "^7.0.0", 10226 - "unist-util-position": "^5.0.0", 10227 - "unist-util-visit": "^5.0.0", 10228 - "vfile": "^6.0.0", 10229 - "web-namespaces": "^2.0.0", 10230 - "zwitch": "^2.0.0" 10231 - }, 10232 - "funding": { 10233 - "type": "opencollective", 10234 - "url": "https://opencollective.com/unified" 10235 - } 10236 - }, 10237 10283 "node_modules/hast-util-to-estree": { 10238 10284 "version": "3.1.0", 10239 10285 "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.0.tgz", ··· 10275 10321 } 10276 10322 }, 10277 10323 "node_modules/hast-util-to-html": { 10278 - "version": "9.0.1", 10279 - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.1.tgz", 10280 - "integrity": "sha512-hZOofyZANbyWo+9RP75xIDV/gq+OUKx+T46IlwERnKmfpwp81XBFbT9mi26ws+SJchA4RVUQwIBJpqEOBhMzEQ==", 10324 + "version": "9.0.5", 10325 + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", 10326 + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", 10281 10327 "dependencies": { 10282 10328 "@types/hast": "^3.0.0", 10283 10329 "@types/unist": "^3.0.0", 10284 10330 "ccount": "^2.0.0", 10285 10331 "comma-separated-tokens": "^2.0.0", 10286 - "hast-util-raw": "^9.0.0", 10287 10332 "hast-util-whitespace": "^3.0.0", 10288 10333 "html-void-elements": "^3.0.0", 10289 10334 "mdast-util-to-hast": "^13.0.0", 10290 - "property-information": "^6.0.0", 10335 + "property-information": "^7.0.0", 10291 10336 "space-separated-tokens": "^2.0.0", 10292 10337 "stringify-entities": "^4.0.0", 10293 10338 "zwitch": "^2.0.4" ··· 10297 10342 "url": "https://opencollective.com/unified" 10298 10343 } 10299 10344 }, 10345 + "node_modules/hast-util-to-html/node_modules/property-information": { 10346 + "version": "7.1.0", 10347 + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", 10348 + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", 10349 + "funding": { 10350 + "type": "github", 10351 + "url": "https://github.com/sponsors/wooorm" 10352 + } 10353 + }, 10300 10354 "node_modules/hast-util-to-jsx-runtime": { 10301 10355 "version": "2.3.2", 10302 10356 "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.2.tgz", ··· 10342 10396 "trim-trailing-lines": "^2.0.0", 10343 10397 "unist-util-position": "^5.0.0", 10344 10398 "unist-util-visit": "^5.0.0" 10345 - }, 10346 - "funding": { 10347 - "type": "opencollective", 10348 - "url": "https://opencollective.com/unified" 10349 - } 10350 - }, 10351 - "node_modules/hast-util-to-parse5": { 10352 - "version": "8.0.0", 10353 - "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", 10354 - "integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==", 10355 - "dependencies": { 10356 - "@types/hast": "^3.0.0", 10357 - "comma-separated-tokens": "^2.0.0", 10358 - "devlop": "^1.0.0", 10359 - "property-information": "^6.0.0", 10360 - "space-separated-tokens": "^2.0.0", 10361 - "web-namespaces": "^2.0.0", 10362 - "zwitch": "^2.0.0" 10363 10399 }, 10364 10400 "funding": { 10365 10401 "type": "opencollective", ··· 11291 11327 "dependencies": { 11292 11328 "jwa": "^1.4.1", 11293 11329 "safe-buffer": "^5.0.1" 11330 + } 11331 + }, 11332 + "node_modules/katex": { 11333 + "version": "0.16.22", 11334 + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", 11335 + "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", 11336 + "funding": [ 11337 + "https://opencollective.com/katex", 11338 + "https://github.com/sponsors/katex" 11339 + ], 11340 + "license": "MIT", 11341 + "dependencies": { 11342 + "commander": "^8.3.0" 11343 + }, 11344 + "bin": { 11345 + "katex": "cli.js" 11346 + } 11347 + }, 11348 + "node_modules/katex/node_modules/commander": { 11349 + "version": "8.3.0", 11350 + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", 11351 + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", 11352 + "license": "MIT", 11353 + "engines": { 11354 + "node": ">= 12" 11294 11355 } 11295 11356 }, 11296 11357 "node_modules/keyv": { ··· 13222 13283 "wrappy": "1" 13223 13284 } 13224 13285 }, 13286 + "node_modules/oniguruma-parser": { 13287 + "version": "0.12.1", 13288 + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", 13289 + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==" 13290 + }, 13291 + "node_modules/oniguruma-to-es": { 13292 + "version": "4.3.3", 13293 + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.3.tgz", 13294 + "integrity": "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==", 13295 + "dependencies": { 13296 + "oniguruma-parser": "^0.12.1", 13297 + "regex": "^6.0.1", 13298 + "regex-recursion": "^6.0.2" 13299 + } 13300 + }, 13225 13301 "node_modules/opener": { 13226 13302 "version": "1.5.2", 13227 13303 "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", ··· 14554 14630 "url": "https://github.com/sponsors/ljharb" 14555 14631 } 14556 14632 }, 14633 + "node_modules/regex": { 14634 + "version": "6.0.1", 14635 + "resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz", 14636 + "integrity": "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==", 14637 + "dependencies": { 14638 + "regex-utilities": "^2.3.0" 14639 + } 14640 + }, 14641 + "node_modules/regex-recursion": { 14642 + "version": "6.0.2", 14643 + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", 14644 + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", 14645 + "dependencies": { 14646 + "regex-utilities": "^2.3.0" 14647 + } 14648 + }, 14649 + "node_modules/regex-utilities": { 14650 + "version": "2.3.0", 14651 + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", 14652 + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==" 14653 + }, 14557 14654 "node_modules/regexp.prototype.flags": { 14558 14655 "version": "1.5.4", 14559 14656 "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", ··· 15229 15326 "dev": true, 15230 15327 "engines": { 15231 15328 "node": ">=8" 15329 + } 15330 + }, 15331 + "node_modules/shiki": { 15332 + "version": "3.8.1", 15333 + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.8.1.tgz", 15334 + "integrity": "sha512-+MYIyjwGPCaegbpBeFN9+oOifI8CKiKG3awI/6h3JeT85c//H2wDW/xCJEGuQ5jPqtbboKNqNy+JyX9PYpGwNg==", 15335 + "dependencies": { 15336 + "@shikijs/core": "3.8.1", 15337 + "@shikijs/engine-javascript": "3.8.1", 15338 + "@shikijs/engine-oniguruma": "3.8.1", 15339 + "@shikijs/langs": "3.8.1", 15340 + "@shikijs/themes": "3.8.1", 15341 + "@shikijs/types": "3.8.1", 15342 + "@shikijs/vscode-textmate": "^10.0.2", 15343 + "@types/hast": "^3.0.4" 15232 15344 } 15233 15345 }, 15234 15346 "node_modules/side-channel": { ··· 16276 16388 } 16277 16389 }, 16278 16390 "node_modules/typescript": { 16279 - "version": "5.5.3", 16280 - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", 16281 - "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", 16391 + "version": "5.8.3", 16392 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", 16393 + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", 16282 16394 "dev": true, 16395 + "license": "Apache-2.0", 16283 16396 "bin": { 16284 16397 "tsc": "bin/tsc", 16285 16398 "tsserver": "bin/tsserver"
+4 -1
package.json
··· 53 53 "fractional-indexing": "^3.2.0", 54 54 "hono": "^4.7.11", 55 55 "ioredis": "^5.6.1", 56 + "katex": "^0.16.22", 56 57 "linkifyjs": "^4.2.0", 57 58 "multiformats": "^13.3.2", 58 59 "next": "15.3.2", ··· 78 79 "remark-stringify": "^11.0.0", 79 80 "replicache": "^14.2.2", 80 81 "sharp": "^0.34.2", 82 + "shiki": "^3.8.1", 81 83 "swr": "^2.3.3", 82 84 "thumbhash": "^0.1.1", 83 85 "twilio": "^5.3.7", ··· 92 94 "@atproto/lex-cli": "^0.6.1", 93 95 "@atproto/lexicon": "^0.4.7", 94 96 "@cloudflare/workers-types": "^4.20240512.0", 97 + "@types/katex": "^0.16.7", 95 98 "@types/node": "^22.15.17", 96 99 "@types/react": "19.1.3", 97 100 "@types/react-dom": "19.1.3", ··· 106 109 "supabase": "^1.187.3", 107 110 "tailwindcss": "^3.4.3", 108 111 "tsx": "^4.19.3", 109 - "typescript": "^5.5.3", 112 + "typescript": "^5.8.3", 110 113 "wrangler": "^3.56.0" 111 114 }, 112 115 "overrides": {
+44 -41
src/hooks/useLongPress.ts
··· 1 - import { useRef, useEffect, useState, useCallback, useMemo } from "react"; 1 + import { useRef, useEffect, useCallback, useMemo } from "react"; 2 2 3 3 export const useLongPress = (cb: () => void, cancel?: boolean) => { 4 4 let longPressTimer = useRef<number>(undefined); 5 5 let isLongPress = useRef(false); 6 - let [startPosition, setStartPosition] = useState<{ 6 + let startPosition = useRef<{ 7 7 x: number; 8 8 y: number; 9 9 } | null>(null); 10 + let mouseMoveListener = useRef<((e: MouseEvent) => void) | null>(null); 11 + let touchMoveListener = useRef<((e: TouchEvent) => void) | null>(null); 12 + 13 + let end = useCallback(() => { 14 + // Clear the starting position 15 + startPosition.current = null; 16 + window.clearTimeout(longPressTimer.current); 17 + longPressTimer.current = undefined; 18 + 19 + // Remove event listeners 20 + if (mouseMoveListener.current) { 21 + window.removeEventListener("mousemove", mouseMoveListener.current); 22 + mouseMoveListener.current = null; 23 + } 24 + if (touchMoveListener.current) { 25 + window.removeEventListener("touchmove", touchMoveListener.current); 26 + touchMoveListener.current = null; 27 + } 28 + }, []); 10 29 11 30 let onPointerDown = useCallback( 12 31 (e: React.MouseEvent) => { 32 + let el = e.target as HTMLElement; 33 + if (el.tagName === "SELECT") return; 13 34 if (e.button === 2) { 14 35 return; 15 36 } 16 37 // Set the starting position 17 - setStartPosition({ x: e.clientX, y: e.clientY }); 18 - isLongPress.current = false; 19 - longPressTimer.current = window.setTimeout(() => { 20 - isLongPress.current = true; 21 - cb(); 22 - }, 500); 23 - }, 24 - [cb], 25 - ); 38 + startPosition.current = { x: e.clientX, y: e.clientY }; 26 39 27 - let end = useCallback(() => { 28 - // Clear the starting position 29 - setStartPosition(null); 30 - window.clearTimeout(longPressTimer.current); 31 - longPressTimer.current = undefined; 32 - }, []); 33 - 34 - useEffect(() => { 35 - if (startPosition) { 36 - let listener = (e: MouseEvent) => { 40 + // Add mousemove and touchmove listeners 41 + mouseMoveListener.current = (e: MouseEvent) => { 42 + if (!startPosition.current) return; 37 43 // Calculate the distance moved 38 44 const distance = Math.sqrt( 39 - Math.pow(e.clientX - startPosition.x, 2) + 40 - Math.pow(e.clientY - startPosition.y, 2), 45 + Math.pow(e.clientX - startPosition.current.x, 2) + 46 + Math.pow(e.clientY - startPosition.current.y, 2), 41 47 ); 42 - // Only end if the distance is greater than 10 pixels 48 + // Only end if the distance is greater than 16 pixels 43 49 if (distance > 16) { 44 50 end(); 45 51 } 46 52 }; 47 - window.addEventListener("mousemove", listener); 48 - let touchListener = (e: TouchEvent) => { 49 - if (e.touches[0]) { 50 - const distance = Math.sqrt( 51 - Math.pow(e.touches[0].clientX - startPosition.x, 2) + 52 - Math.pow(e.touches[0].clientY - startPosition.y, 2), 53 - ); 54 - if (distance > 16) { 55 - end(); 56 - } 53 + 54 + touchMoveListener.current = (e: TouchEvent) => { 55 + if (!startPosition.current || !e.touches[0]) return; 56 + const distance = Math.sqrt( 57 + Math.pow(e.touches[0].clientX - startPosition.current.x, 2) + 58 + Math.pow(e.touches[0].clientY - startPosition.current.y, 2), 59 + ); 60 + if (distance > 16) { 61 + end(); 57 62 } 58 63 }; 59 - window.addEventListener("touchmove", touchListener); 60 64 61 - return () => { 62 - window.removeEventListener("mousemove", listener); 63 - window.removeEventListener("touchmove", touchListener); 64 - }; 65 - } 66 - }, [startPosition, end]); 65 + window.addEventListener("mousemove", mouseMoveListener.current); 66 + window.addEventListener("touchmove", touchMoveListener.current); 67 + }, 68 + [cb, end], 69 + ); 67 70 68 71 let click = useCallback((e: React.MouseEvent | React.PointerEvent) => { 69 72 if (isLongPress.current) e.preventDefault();
+19 -1
src/replicache/attributes.ts
··· 83 83 type: "bluesky-post", 84 84 cardinality: "one", 85 85 }, 86 + "block/math": { 87 + type: "string", 88 + cardinality: "one", 89 + }, 90 + "block/code": { 91 + type: "string", 92 + cardinality: "one", 93 + }, 94 + "block/code-language": { 95 + type: "string", 96 + cardinality: "one", 97 + }, 86 98 } as const; 87 99 88 100 const MailboxAttributes = { ··· 231 243 type: "color", 232 244 cardinality: "one", 233 245 }, 246 + "theme/code-theme": { 247 + type: "string", 248 + cardinality: "one", 249 + }, 234 250 } as const; 235 251 236 252 export const Attributes = { ··· 313 329 | "embed" 314 330 | "button" 315 331 | "poll" 316 - | "bluesky-post"; 332 + | "bluesky-post" 333 + | "math" 334 + | "code"; 317 335 }; 318 336 "canvas-pattern-union": { 319 337 type: "canvas-pattern-union";
+40 -5
src/utils/focusBlock.ts
··· 5 5 6 6 import { useEditorStates } from "src/state/useEditorState"; 7 7 import { scrollIntoViewIfNeeded } from "./scrollIntoViewIfNeeded"; 8 + import { getPosAtCoordinates } from "./getCoordinatesInTextarea"; 9 + import { flushSync } from "react-dom"; 8 10 9 11 export function focusBlock( 10 12 block: Pick<Block, "type" | "value" | "parent">, 11 13 position: Position, 12 14 ) { 13 15 // focus the block 14 - useUIState.getState().setSelectedBlock(block); 15 - useUIState.getState().setFocusedBlock({ 16 - entityType: "block", 17 - entityID: block.value, 18 - parent: block.parent, 16 + flushSync(() => { 17 + useUIState.getState().setSelectedBlock(block); 18 + useUIState.getState().setFocusedBlock({ 19 + entityType: "block", 20 + entityID: block.value, 21 + parent: block.parent, 22 + }); 19 23 }); 20 24 scrollIntoViewIfNeeded( 21 25 document.getElementById(elementId.block(block.value).container), 22 26 false, 23 27 ); 28 + if (block.type === "math" || block.type === "code") { 29 + let el = document.getElementById( 30 + elementId.block(block.value).input, 31 + ) as HTMLTextAreaElement; 32 + let pos; 33 + if (position.type === "start") { 34 + pos = { offset: 0 }; 35 + } 36 + 37 + if (position.type === "end") { 38 + pos = { offset: el.textContent?.length || 0 }; 39 + } 40 + if (position.type === "top" || position.type === "bottom") { 41 + let inputRect = el?.getBoundingClientRect(); 42 + let left = Math.max(position.left, inputRect?.left || 0); 43 + let top = 44 + position.type === "top" 45 + ? (inputRect?.top || 0) + 10 46 + : (inputRect?.bottom || 0) - 10; 47 + pos = getPosAtCoordinates(left, top); 48 + } 49 + 50 + if (pos?.offset !== undefined) { 51 + el?.focus(); 52 + requestAnimationFrame(() => { 53 + el?.setSelectionRange(pos.offset, pos.offset); 54 + }); 55 + } 56 + } 24 57 25 58 // if its not a text block, that's all we need to do 26 59 if (block.type !== "text" && block.type !== "heading") { ··· 44 77 break; 45 78 } 46 79 case "top": { 80 + console.log(position.left); 47 81 pos = nextBlock.view.posAtCoords({ 48 82 top: nextBlockViewClientRect.top + 12, 49 83 left: position.left, 50 84 }); 85 + console.log(pos); 51 86 break; 52 87 } 53 88 case "bottom": {
+26
src/utils/getBlocksAsHTML.tsx
··· 7 7 import { RenderYJSFragment } from "components/Blocks/TextBlock/RenderYJSFragment"; 8 8 import { Block } from "components/Blocks/Block"; 9 9 import { List, parseBlocksToList } from "./parseBlocksToList"; 10 + import Katex from "katex"; 10 11 11 12 export async function getBlocksAsHTML( 12 13 rep: Replicache<ReplicacheMutators>, ··· 75 76 ) { 76 77 let wrapper: undefined | "h1" | "h2" | "h3"; 77 78 let [alignment] = await scanIndex(tx).eav(b.value, "block/text-alignment"); 79 + if (b.type === "code") { 80 + let [code] = await scanIndex(tx).eav(b.value, "block/code"); 81 + let [lang] = await scanIndex(tx).eav(b.value, "block/code-language"); 82 + return renderToStaticMarkup( 83 + <pre data-lang={lang?.data.value}>{code?.data.value || ""}</pre>, 84 + ); 85 + } 86 + if (b.type === "math") { 87 + let [math] = await scanIndex(tx).eav(b.value, "block/math"); 88 + const html = Katex.renderToString(math?.data.value || "", { 89 + displayMode: true, 90 + throwOnError: false, 91 + macros: { 92 + "\\f": "#1f(#2)", 93 + }, 94 + }); 95 + return renderToStaticMarkup( 96 + <div 97 + data-type="math" 98 + data-tex={math?.data.value} 99 + data-alignment={alignment?.data.value} 100 + dangerouslySetInnerHTML={{ __html: html }} 101 + />, 102 + ); 103 + } 78 104 if (b.type === "image") { 79 105 let [src] = await scanIndex(tx).eav(b.value, "block/image"); 80 106 if (!src) return "";
+130
src/utils/getCoordinatesInTextarea.ts
··· 1 + //https://github.com/component/textarea-caret-position/blob/master/index.js 2 + let properties = [ 3 + "direction", // RTL support 4 + "boxSizing", 5 + "width", // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does 6 + "height", 7 + "overflowX", 8 + "overflowY", // copy the scrollbar for IE 9 + 10 + "borderTopWidth", 11 + "borderRightWidth", 12 + "borderBottomWidth", 13 + "borderLeftWidth", 14 + "borderStyle", 15 + 16 + "paddingTop", 17 + "paddingRight", 18 + "paddingBottom", 19 + "paddingLeft", 20 + 21 + // https://developer.mozilla.org/en-US/docs/Web/CSS/font 22 + "fontStyle", 23 + "fontVariant", 24 + "fontWeight", 25 + "fontStretch", 26 + "fontSize", 27 + "fontSizeAdjust", 28 + "lineHeight", 29 + "fontFamily", 30 + 31 + "textAlign", 32 + "textTransform", 33 + "textIndent", 34 + "textDecoration", // might not make a difference, but better be safe 35 + 36 + "letterSpacing", 37 + "wordSpacing", 38 + 39 + "tabSize", 40 + "MozTabSize", 41 + ]; 42 + 43 + var isBrowser = typeof window !== "undefined"; 44 + //@ts-ignore 45 + var isFirefox = isBrowser && window.mozInnerScreenX != null; 46 + 47 + export function getCoordinatesInTextarea( 48 + element: HTMLTextAreaElement, 49 + position: number, 50 + ) { 51 + if (!isBrowser) { 52 + throw new Error( 53 + "textarea-caret-position#getCaretCoordinates should only be called in a browser", 54 + ); 55 + } 56 + 57 + // The mirror div will replicate the textarea's style 58 + var div = document.createElement("div"); 59 + div.id = "input-textarea-caret-position-mirror-div"; 60 + document.body.appendChild(div); 61 + 62 + var style = div.style; 63 + var computed = window.getComputedStyle(element); 64 + var isInput = element.nodeName === "INPUT"; 65 + 66 + // Default textarea styles 67 + style.whiteSpace = "pre-wrap"; 68 + if (!isInput) style.wordWrap = "break-word"; // only for textarea-s 69 + 70 + // Position off-screen 71 + style.position = "absolute"; // required to return coordinates properly 72 + style.visibility = "hidden"; // not 'display: none' because we want rendering 73 + 74 + // Transfer the element's properties to the div 75 + properties.forEach(function (prop) { 76 + //@ts-ignore 77 + style[prop] = computed[prop]; 78 + }); 79 + 80 + if (isFirefox) { 81 + // Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275 82 + if (element.scrollHeight > parseInt(computed.height)) 83 + style.overflowY = "scroll"; 84 + } else { 85 + style.overflow = "hidden"; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll' 86 + } 87 + 88 + div.textContent = element.value.substring(0, position); 89 + // The second special handling for input type="text" vs textarea: 90 + // spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037 91 + if (isInput) div.textContent = div.textContent.replace(/\s/g, "\u00a0"); 92 + 93 + var span = document.createElement("span"); 94 + // Wrapping must be replicated *exactly*, including when a long word gets 95 + // onto the next line, with whitespace at the end of the line before (#7). 96 + // The *only* reliable way to do that is to copy the *entire* rest of the 97 + // textarea's content into the <span> created at the caret position. 98 + // For inputs, just '.' would be enough, but no need to bother. 99 + span.textContent = element.value.substring(position) || "."; // || because a completely empty faux span doesn't render at all 100 + div.appendChild(span); 101 + 102 + var coordinates = { 103 + top: span.offsetTop + parseInt(computed["borderTopWidth"]), 104 + left: span.offsetLeft + parseInt(computed["borderLeftWidth"]), 105 + height: parseInt(computed["lineHeight"]), 106 + }; 107 + 108 + document.body.removeChild(div); 109 + return coordinates; 110 + } 111 + 112 + export function getPosAtCoordinates(x: number, y: number) { 113 + let textNode; 114 + let offset; 115 + 116 + if (document.caretPositionFromPoint) { 117 + let caretPosition = document.caretPositionFromPoint(x, y); 118 + textNode = caretPosition?.offsetNode; 119 + offset = caretPosition?.offset; 120 + } else if (document.caretRangeFromPoint) { 121 + // Use WebKit-proprietary fallback method 122 + let range = document.caretRangeFromPoint(x, y); 123 + textNode = range?.startContainer; 124 + offset = range?.startOffset; 125 + } 126 + return { 127 + textNode, 128 + offset, 129 + }; 130 + }