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

+1189 -158
+38 -7
actions/publishToPublication.ts
··· 14 PubLeafletPagesLinearDocument, 15 PubLeafletRichtextFacet, 16 PubLeafletBlocksWebsite, 17 } from "lexicons/api"; 18 import { Block } from "components/Blocks/Block"; 19 import { TID } from "@atproto/common"; ··· 95 blocks, 96 imageMap, 97 scan, 98 ); 99 100 let existingRecord = ··· 149 blocks: Block[], 150 imageMap: Map<string, BlobRef>, 151 scan: ReturnType<typeof scanIndexLocal>, 152 ): PubLeafletPagesLinearDocument.Block[] { 153 let parsedBlocks = parseBlocksToList(blocks); 154 return parsedBlocks.flatMap((blockOrList) => { ··· 162 : alignmentValue === "right" 163 ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" 164 : undefined; 165 - let b = blockToRecord(blockOrList.block, imageMap, scan); 166 if (!b) return []; 167 let block: PubLeafletPagesLinearDocument.Block = { 168 $type: "pub.leaflet.pages.linearDocument#block", ··· 175 $type: "pub.leaflet.pages.linearDocument#block", 176 block: { 177 $type: "pub.leaflet.blocks.unorderedList", 178 - children: childrenToRecord(blockOrList.children, imageMap, scan), 179 }, 180 }; 181 return [block]; ··· 187 children: List[], 188 imageMap: Map<string, BlobRef>, 189 scan: ReturnType<typeof scanIndexLocal>, 190 ) { 191 return children.flatMap((child) => { 192 - let content = blockToRecord(child.block, imageMap, scan); 193 if (!content) return []; 194 let record: PubLeafletBlocksUnorderedList.ListItem = { 195 $type: "pub.leaflet.blocks.unorderedList#listItem", 196 content, 197 - children: childrenToRecord(child.children, imageMap, scan), 198 }; 199 return record; 200 }); ··· 203 b: Block, 204 imageMap: Map<string, BlobRef>, 205 scan: ReturnType<typeof scanIndexLocal>, 206 ) { 207 const getBlockContent = (b: string) => { 208 let [content] = scan.eav(b, "block/text"); ··· 219 b.type !== "text" && 220 b.type !== "heading" && 221 b.type !== "image" && 222 - b.type !== "link" 223 ) 224 return; 225 - let alignmentValue = 226 - scan.eav(b.value, "block/text-alignment")[0]?.data.value || "left"; 227 228 if (b.type === "heading") { 229 let [headingLevel] = scan.eav(b.value, "block/heading-level"); ··· 279 src: src.data.value, 280 description: description.data.value, 281 title: title.data.value, 282 }; 283 return block; 284 }
··· 14 PubLeafletPagesLinearDocument, 15 PubLeafletRichtextFacet, 16 PubLeafletBlocksWebsite, 17 + PubLeafletBlocksCode, 18 + PubLeafletBlocksMath, 19 } from "lexicons/api"; 20 import { Block } from "components/Blocks/Block"; 21 import { TID } from "@atproto/common"; ··· 97 blocks, 98 imageMap, 99 scan, 100 + root_entity, 101 ); 102 103 let existingRecord = ··· 152 blocks: Block[], 153 imageMap: Map<string, BlobRef>, 154 scan: ReturnType<typeof scanIndexLocal>, 155 + root_entity: string, 156 ): PubLeafletPagesLinearDocument.Block[] { 157 let parsedBlocks = parseBlocksToList(blocks); 158 return parsedBlocks.flatMap((blockOrList) => { ··· 166 : alignmentValue === "right" 167 ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" 168 : undefined; 169 + let b = blockToRecord(blockOrList.block, imageMap, scan, root_entity); 170 if (!b) return []; 171 let block: PubLeafletPagesLinearDocument.Block = { 172 $type: "pub.leaflet.pages.linearDocument#block", ··· 179 $type: "pub.leaflet.pages.linearDocument#block", 180 block: { 181 $type: "pub.leaflet.blocks.unorderedList", 182 + children: childrenToRecord( 183 + blockOrList.children, 184 + imageMap, 185 + scan, 186 + root_entity, 187 + ), 188 }, 189 }; 190 return [block]; ··· 196 children: List[], 197 imageMap: Map<string, BlobRef>, 198 scan: ReturnType<typeof scanIndexLocal>, 199 + root_entity: string, 200 ) { 201 return children.flatMap((child) => { 202 + let content = blockToRecord(child.block, imageMap, scan, root_entity); 203 if (!content) return []; 204 let record: PubLeafletBlocksUnorderedList.ListItem = { 205 $type: "pub.leaflet.blocks.unorderedList#listItem", 206 content, 207 + children: childrenToRecord(child.children, imageMap, scan, root_entity), 208 }; 209 return record; 210 }); ··· 213 b: Block, 214 imageMap: Map<string, BlobRef>, 215 scan: ReturnType<typeof scanIndexLocal>, 216 + root_entity: string, 217 ) { 218 const getBlockContent = (b: string) => { 219 let [content] = scan.eav(b, "block/text"); ··· 230 b.type !== "text" && 231 b.type !== "heading" && 232 b.type !== "image" && 233 + b.type !== "link" && 234 + b.type !== "code" && 235 + b.type !== "math" 236 ) 237 return; 238 239 if (b.type === "heading") { 240 let [headingLevel] = scan.eav(b.value, "block/heading-level"); ··· 290 src: src.data.value, 291 description: description.data.value, 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 || "", 313 }; 314 return block; 315 }
+10
app/globals.css
··· 157 ); 158 } 159 160 .highlight { 161 @apply px-[1px]; 162 @apply py-[1px];
··· 157 ); 158 } 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 + 170 .highlight { 171 @apply px-[1px]; 172 @apply py-[1px];
+21 -1
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 1 import { 2 PubLeafletBlocksHeader, 3 PubLeafletBlocksImage, 4 PubLeafletBlocksText, ··· 12 import { Popover } from "components/Popover"; 13 import { theme } from "tailwind.config"; 14 import { ImageAltSmall } from "components/Icons/ImageAlt"; 15 16 export function PostContent({ 17 blocks, ··· 29 ); 30 } 31 32 - let Block = ({ 33 block, 34 did, 35 isList, ··· 69 /> 70 ))} 71 </ul> 72 ); 73 } 74 case PubLeafletBlocksWebsite.isMain(b.block): {
··· 1 import { 2 + PubLeafletBlocksMath, 3 + PubLeafletBlocksCode, 4 PubLeafletBlocksHeader, 5 PubLeafletBlocksImage, 6 PubLeafletBlocksText, ··· 14 import { Popover } from "components/Popover"; 15 import { theme } from "tailwind.config"; 16 import { ImageAltSmall } from "components/Icons/ImageAlt"; 17 + import { codeToHtml } from "shiki"; 18 + import Katex from "katex"; 19 + import { StaticMathBlock } from "./StaticMathBlock"; 20 21 export function PostContent({ 22 blocks, ··· 34 ); 35 } 36 37 + let Block = async ({ 38 block, 39 did, 40 isList, ··· 74 /> 75 ))} 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 + /> 92 ); 93 } 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 import { 2 PubLeafletBlocksHeader, 3 PubLeafletBlocksImage, 4 PubLeafletBlocksText, 5 PubLeafletBlocksUnorderedList, 6 PubLeafletBlocksWebsite, ··· 9 } from "lexicons/api"; 10 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 import { TextBlock } from "./TextBlock"; 12 13 export function StaticPostContent({ 14 blocks, ··· 26 ); 27 } 28 29 - let Block = ({ 30 block, 31 did, 32 isList, ··· 38 let b = block; 39 40 switch (true) { 41 case PubLeafletBlocksUnorderedList.isMain(b.block): { 42 return ( 43 <ul>
··· 1 import { 2 + PubLeafletBlocksCode, 3 PubLeafletBlocksHeader, 4 PubLeafletBlocksImage, 5 + PubLeafletBlocksMath, 6 PubLeafletBlocksText, 7 PubLeafletBlocksUnorderedList, 8 PubLeafletBlocksWebsite, ··· 11 } from "lexicons/api"; 12 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 13 import { TextBlock } from "./TextBlock"; 14 + import { StaticMathBlock } from "./StaticMathBlock"; 15 + import { codeToHtml } from "shiki"; 16 17 export function StaticPostContent({ 18 blocks, ··· 30 ); 31 } 32 33 + let Block = async ({ 34 block, 35 did, 36 isList, ··· 42 let b = block; 43 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 + } 60 case PubLeafletBlocksUnorderedList.isMain(b.block): { 61 return ( 62 <ul>
+37 -23
app/lish/[did]/[publication]/rss/route.ts
··· 18 params: Promise<{ publication: string; did: string }>; 19 }, 20 ) { 21 - let renderToStaticMarkup = await import("react-dom/server").then( 22 - (module) => module.renderToStaticMarkup, 23 ); 24 let params = await props.params; 25 let { result: publication } = await get_publication_data.handler( ··· 46 }, 47 }); 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( 67 createElement(StaticPostContent, { blocks, did: uri.host }), 68 - ), 69 - }); 70 - }); 71 return new Response(feed.rss2(), { 72 headers: { 73 "Content-Type": "text/xml",
··· 18 params: Promise<{ publication: string; did: string }>; 19 }, 20 ) { 21 + let renderToReadableStream = await import("react-dom/server").then( 22 + (module) => module.renderToReadableStream, 23 ); 24 let params = await props.params; 25 let { result: publication } = await get_publication_data.handler( ··· 46 }, 47 }); 48 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( 62 createElement(StaticPostContent, { blocks, did: uri.host }), 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 + ); 85 return new Response(feed.rss2(), { 86 headers: { 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 import { CheckboxChecked } from "components/Icons/CheckboxChecked"; 27 import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; 28 import { LockTiny } from "components/Icons/LockTiny"; 29 30 export type Block = { 31 factID: string; ··· 168 BlockProps & { preview?: boolean } 169 >; 170 } = { 171 card: PageLinkBlock, 172 text: TextBlock, 173 heading: TextBlock,
··· 26 import { CheckboxChecked } from "components/Icons/CheckboxChecked"; 27 import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; 28 import { LockTiny } from "components/Icons/LockTiny"; 29 + import { MathBlock } from "./MathBlock"; 30 + import { CodeBlock } from "./CodeBlock"; 31 32 export type Block = { 33 factID: string; ··· 170 BlockProps & { preview?: boolean } 171 >; 172 } = { 173 + code: CodeBlock, 174 + math: MathBlock, 175 card: PageLinkBlock, 176 text: TextBlock, 177 heading: TextBlock,
+20
components/Blocks/BlockCommands.tsx
··· 29 import { LinkSmall } from "components/Icons/LinkSmall"; 30 import { BlockRSVPSmall } from "components/Icons/BlockRSVPSmall"; 31 import { ListUnorderedSmall } from "components/Toolbar/ListToolbar"; 32 33 type Props = { 34 parent: string; ··· 265 hiddenInPublication: true, 266 onSelect: async (rep, props) => { 267 createBlockWithType(rep, props, "bluesky-post"); 268 }, 269 }, 270
··· 29 import { LinkSmall } from "components/Icons/LinkSmall"; 30 import { BlockRSVPSmall } from "components/Icons/BlockRSVPSmall"; 31 import { ListUnorderedSmall } from "components/Toolbar/ListToolbar"; 32 + import { BlockMathSmall } from "components/Icons/BlockMathSmall"; 33 + import { BlockCodeSmall } from "components/Icons/BlockCodeSmall"; 34 35 type Props = { 36 parent: string; ··· 267 hiddenInPublication: true, 268 onSelect: async (rep, props) => { 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"); 288 }, 289 }, 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 import { focusBlock } from "src/utils/focusBlock"; 11 import { schema } from "./schema"; 12 import { useUIState } from "src/useUIState"; 13 export const inputrules = ( 14 propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 15 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, ··· 84 .removeStoredMark(schema.marks.em); 85 return tr; 86 } 87 return null; 88 }), 89
··· 10 import { focusBlock } from "src/utils/focusBlock"; 11 import { schema } from "./schema"; 12 import { useUIState } from "src/useUIState"; 13 + import { flushSync } from "react-dom"; 14 export const inputrules = ( 15 propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 16 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, ··· 85 .removeStoredMark(schema.marks.em); 86 return tr; 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); 103 return null; 104 }), 105
+51 -1
components/Blocks/TextBlock/useHandlePaste.ts
··· 208 type = "text"; 209 break; 210 } 211 case "P": { 212 type = "text"; 213 break; ··· 312 } 313 } 314 } 315 if (child.tagName === "IMG") { 316 let src = child.getAttribute("src"); 317 if (src) { ··· 325 }); 326 }); 327 } 328 } 329 330 if (child.tagName === "DIV" && child.getAttribute("data-entityid")) { ··· 503 if ( 504 [ 505 "P", 506 "H1", 507 "H2", 508 "H3", ··· 515 "A", 516 "SPAN", 517 ].includes(elementNode.tagName) || 518 - elementNode.getAttribute("data-entityid") 519 ) { 520 htmlBlocks.push(elementNode); 521 } else {
··· 208 type = "text"; 209 break; 210 } 211 + case "PRE": { 212 + type = "code"; 213 + break; 214 + } 215 case "P": { 216 type = "text"; 217 break; ··· 316 } 317 } 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 + } 348 if (child.tagName === "IMG") { 349 let src = child.getAttribute("src"); 350 if (src) { ··· 358 }); 359 }); 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 + ]); 376 } 377 378 if (child.tagName === "DIV" && child.getAttribute("data-entityid")) { ··· 551 if ( 552 [ 553 "P", 554 + "PRE", 555 "H1", 556 "H2", 557 "H3", ··· 564 "A", 565 "SPAN", 566 ].includes(elementNode.tagName) || 567 + elementNode.getAttribute("data-entityid") || 568 + elementNode.getAttribute("data-tex") 569 ) { 570 htmlBlocks.push(elementNode); 571 } else {
+1
components/Blocks/useBlockKeyboardHandlers.ts
··· 53 (el.tagName === "LABEL" || 54 el.tagName === "INPUT" || 55 el.tagName === "TEXTAREA" || 56 el.contentEditable === "true") && 57 !isTextBlock[props.type] 58 ) {
··· 53 (el.tagName === "LABEL" || 54 el.tagName === "INPUT" || 55 el.tagName === "TEXTAREA" || 56 + el.tagName === "SELECT" || 57 el.contentEditable === "true") && 58 !isTextBlock[props.type] 59 ) {
+3
components/Blocks/useBlockMouseHandlers.ts
··· 18 (e: MouseEvent) => { 19 if ((e.target as Element).getAttribute("data-draggable")) return; 20 if ((e.target as Element).tagName === "BUTTON") return; 21 if (isMobile) return; 22 if (!entity_set.permissions.write) return; 23 useSelectingMouse.setState({ start: props.value }); ··· 30 e.preventDefault(); 31 useUIState.getState().addBlockToSelection(props); 32 } else { 33 useUIState.getState().setFocusedBlock({ 34 entityType: "block", 35 entityID: props.value,
··· 18 (e: MouseEvent) => { 19 if ((e.target as Element).getAttribute("data-draggable")) return; 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; 23 if (isMobile) return; 24 if (!entity_set.permissions.write) return; 25 useSelectingMouse.setState({ start: props.value }); ··· 32 e.preventDefault(); 33 useUIState.getState().addBlockToSelection(props); 34 } else { 35 + if (e.isDefaultPrevented()) return; 36 useUIState.getState().setFocusedBlock({ 37 entityType: "block", 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 savedSelection.current = null; 515 if ( 516 initialContentEditableParent.current && 517 getContentEditableParent(e.target as Node) !== 518 initialContentEditableParent.current 519 ) { ··· 617 function getContentEditableParent(e: Node | null): Node | null { 618 let element: Node | null = e; 619 while (element && element !== document) { 620 - if ((element as HTMLElement).contentEditable === "true") { 621 return element; 622 } 623 element = element.parentNode;
··· 514 savedSelection.current = null; 515 if ( 516 initialContentEditableParent.current && 517 + !(e.target as Element).getAttribute("data-draggable") && 518 getContentEditableParent(e.target as Node) !== 519 initialContentEditableParent.current 520 ) { ··· 618 function getContentEditableParent(e: Node | null): Node | null { 619 let element: Node | null = e; 620 while (element && element !== document) { 621 + if ( 622 + (element as HTMLElement).contentEditable === "true" || 623 + (element as HTMLElement).getAttribute("data-editable-block") 624 + ) { 625 return element; 626 } 627 element = element.parentNode;
+24 -23
components/utils/AutosizeTextarea.tsx
··· 7 } from "react"; 8 import styles from "./textarea-styles.module.css"; 9 10 - type Props = React.DetailedHTMLProps< 11 React.TextareaHTMLAttributes<HTMLTextAreaElement>, 12 HTMLTextAreaElement 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); 18 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 - ); 35 36 export const AsyncValueAutosizeTextarea = forwardRef< 37 HTMLTextAreaElement, 38 - Props 39 - >((props: Props, ref) => { 40 let [intermediateState, setIntermediateState] = useState( 41 props.value as string, 42 );
··· 7 } from "react"; 8 import styles from "./textarea-styles.module.css"; 9 10 + export type AutosizeTextareaProps = React.DetailedHTMLProps< 11 React.TextareaHTMLAttributes<HTMLTextAreaElement>, 12 HTMLTextAreaElement 13 >; 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); 20 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 + }); 36 37 export const AsyncValueAutosizeTextarea = forwardRef< 38 HTMLTextAreaElement, 39 + AutosizeTextareaProps 40 + >((props: AutosizeTextareaProps, ref) => { 41 let [intermediateState, setIntermediateState] = useState( 42 props.value as string, 43 );
+1 -1
components/utils/textarea-styles.module.css
··· 11 content: attr(data-replicated-value) " "; 12 13 /* This is how textarea text behaves */ 14 - white-space: pre-wrap; 15 16 /* Hidden from view, clicks, and screen readers */ 17 visibility: hidden;
··· 11 content: attr(data-replicated-value) " "; 12 13 /* This is how textarea text behaves */ 14 + white-space: pre; 15 16 /* Hidden from view, clicks, and screen readers */ 17 visibility: hidden;
+4
lexicons/api/index.ts
··· 7 import { OmitKey, Un$Typed } from './util' 8 import * as PubLeafletDocument from './types/pub/leaflet/document' 9 import * as PubLeafletPublication from './types/pub/leaflet/publication' 10 import * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header' 11 import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 12 import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 13 import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 14 import * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' ··· 34 35 export * as PubLeafletDocument from './types/pub/leaflet/document' 36 export * as PubLeafletPublication from './types/pub/leaflet/publication' 37 export * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header' 38 export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 39 export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 40 export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 41 export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website'
··· 7 import { OmitKey, Un$Typed } from './util' 8 import * as PubLeafletDocument from './types/pub/leaflet/document' 9 import * as PubLeafletPublication from './types/pub/leaflet/publication' 10 + import * as PubLeafletBlocksCode from './types/pub/leaflet/blocks/code' 11 import * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header' 12 import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 13 + import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 14 import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 15 import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 16 import * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' ··· 36 37 export * as PubLeafletDocument from './types/pub/leaflet/document' 38 export * as PubLeafletPublication from './types/pub/leaflet/publication' 39 + export * as PubLeafletBlocksCode from './types/pub/leaflet/blocks/code' 40 export * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header' 41 export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 42 + export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 43 export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 44 export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 45 export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website'
+40
lexicons/api/lexicons.ts
··· 161 }, 162 }, 163 }, 164 PubLeafletBlocksHeader: { 165 lexicon: 1, 166 id: 'pub.leaflet.blocks.header', ··· 221 }, 222 height: { 223 type: 'integer', 224 }, 225 }, 226 }, ··· 364 'lex:pub.leaflet.blocks.image', 365 'lex:pub.leaflet.blocks.unorderedList', 366 'lex:pub.leaflet.blocks.website', 367 ], 368 }, 369 alignment: { ··· 1592 export const ids = { 1593 PubLeafletDocument: 'pub.leaflet.document', 1594 PubLeafletPublication: 'pub.leaflet.publication', 1595 PubLeafletBlocksHeader: 'pub.leaflet.blocks.header', 1596 PubLeafletBlocksImage: 'pub.leaflet.blocks.image', 1597 PubLeafletBlocksText: 'pub.leaflet.blocks.text', 1598 PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', 1599 PubLeafletBlocksWebsite: 'pub.leaflet.blocks.website',
··· 161 }, 162 }, 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 + }, 185 PubLeafletBlocksHeader: { 186 lexicon: 1, 187 id: 'pub.leaflet.blocks.header', ··· 242 }, 243 height: { 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', 260 }, 261 }, 262 }, ··· 400 'lex:pub.leaflet.blocks.image', 401 'lex:pub.leaflet.blocks.unorderedList', 402 'lex:pub.leaflet.blocks.website', 403 + 'lex:pub.leaflet.blocks.math', 404 + 'lex:pub.leaflet.blocks.code', 405 ], 406 }, 407 alignment: { ··· 1630 export const ids = { 1631 PubLeafletDocument: 'pub.leaflet.document', 1632 PubLeafletPublication: 'pub.leaflet.publication', 1633 + PubLeafletBlocksCode: 'pub.leaflet.blocks.code', 1634 PubLeafletBlocksHeader: 'pub.leaflet.blocks.header', 1635 PubLeafletBlocksImage: 'pub.leaflet.blocks.image', 1636 + PubLeafletBlocksMath: 'pub.leaflet.blocks.math', 1637 PubLeafletBlocksText: 'pub.leaflet.blocks.text', 1638 PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', 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 import type * as PubLeafletBlocksImage from '../blocks/image' 11 import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList' 12 import type * as PubLeafletBlocksWebsite from '../blocks/website' 13 14 const is$typed = _is$typed, 15 validate = _validate ··· 38 | $Typed<PubLeafletBlocksImage.Main> 39 | $Typed<PubLeafletBlocksUnorderedList.Main> 40 | $Typed<PubLeafletBlocksWebsite.Main> 41 | { $type: string } 42 alignment?: 43 | 'lex:pub.leaflet.pages.linearDocument#textAlignLeft'
··· 10 import type * as PubLeafletBlocksImage from '../blocks/image' 11 import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList' 12 import type * as PubLeafletBlocksWebsite from '../blocks/website' 13 + import type * as PubLeafletBlocksMath from '../blocks/math' 14 + import type * as PubLeafletBlocksCode from '../blocks/code' 15 16 const is$typed = _is$typed, 17 validate = _validate ··· 40 | $Typed<PubLeafletBlocksImage.Main> 41 | $Typed<PubLeafletBlocksUnorderedList.Main> 42 | $Typed<PubLeafletBlocksWebsite.Main> 43 + | $Typed<PubLeafletBlocksMath.Main> 44 + | $Typed<PubLeafletBlocksCode.Main> 45 | { $type: string } 46 alignment?: 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 "pub.leaflet.blocks.header", 28 "pub.leaflet.blocks.image", 29 "pub.leaflet.blocks.unorderedList", 30 - "pub.leaflet.blocks.website" 31 ] 32 }, 33 "alignment": {
··· 27 "pub.leaflet.blocks.header", 28 "pub.leaflet.blocks.image", 29 "pub.leaflet.blocks.unorderedList", 30 + "pub.leaflet.blocks.website", 31 + "pub.leaflet.blocks.math", 32 + "pub.leaflet.blocks.code" 33 ] 34 }, 35 "alignment": {
+31
lexicons/src/blocks.ts
··· 18 }, 19 }, 20 }; 21 22 export const PubLeafletBlocksWebsite: LexiconDoc = { 23 lexicon: 1, ··· 119 PubLeafletBlocksImage, 120 PubLeafletBlocksUnorderedList, 121 PubLeafletBlocksWebsite, 122 ]; 123 export const BlockUnion: LexRefUnion = { 124 type: "union",
··· 18 }, 19 }, 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 + }; 50 51 export const PubLeafletBlocksWebsite: LexiconDoc = { 52 lexicon: 1, ··· 148 PubLeafletBlocksImage, 149 PubLeafletBlocksUnorderedList, 150 PubLeafletBlocksWebsite, 151 + PubLeafletBlocksMath, 152 + PubLeafletBlocksCode, 153 ]; 154 export const BlockUnion: LexRefUnion = { 155 type: "union",
+164 -51
package-lock.json
··· 43 "fractional-indexing": "^3.2.0", 44 "hono": "^4.7.11", 45 "ioredis": "^5.6.1", 46 "linkifyjs": "^4.2.0", 47 "multiformats": "^13.3.2", 48 "next": "15.3.2", ··· 68 "remark-stringify": "^11.0.0", 69 "replicache": "^14.2.2", 70 "sharp": "^0.34.2", 71 "swr": "^2.3.3", 72 "thumbhash": "^0.1.1", 73 "twilio": "^5.3.7", ··· 82 "@atproto/lex-cli": "^0.6.1", 83 "@atproto/lexicon": "^0.4.7", 84 "@cloudflare/workers-types": "^4.20240512.0", 85 "@types/node": "^22.15.17", 86 "@types/react": "19.1.3", 87 "@types/react-dom": "19.1.3", ··· 96 "supabase": "^1.187.3", 97 "tailwindcss": "^3.4.3", 98 "tsx": "^4.19.3", 99 - "typescript": "^5.5.3", 100 "wrangler": "^3.56.0" 101 } 102 }, ··· 5671 "integrity": "sha512-qC/xYId4NMebE6w/V33Fh9gWxLgURiNYgVNObbJl2LZv0GUUItCcCqC5axQSwRaAgaxl2mELq1rMzlswaQ0Zxg==", 5672 "dev": true 5673 }, 5674 "node_modules/@supabase/auth-js": { 5675 "version": "2.64.2", 5676 "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.64.2.tgz", ··· 5900 "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", 5901 "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", 5902 "dev": true 5903 }, 5904 "node_modules/@types/linkify-it": { 5905 "version": "5.0.0", ··· 10210 "url": "https://opencollective.com/unified" 10211 } 10212 }, 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 "node_modules/hast-util-to-estree": { 10238 "version": "3.1.0", 10239 "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.0.tgz", ··· 10275 } 10276 }, 10277 "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==", 10281 "dependencies": { 10282 "@types/hast": "^3.0.0", 10283 "@types/unist": "^3.0.0", 10284 "ccount": "^2.0.0", 10285 "comma-separated-tokens": "^2.0.0", 10286 - "hast-util-raw": "^9.0.0", 10287 "hast-util-whitespace": "^3.0.0", 10288 "html-void-elements": "^3.0.0", 10289 "mdast-util-to-hast": "^13.0.0", 10290 - "property-information": "^6.0.0", 10291 "space-separated-tokens": "^2.0.0", 10292 "stringify-entities": "^4.0.0", 10293 "zwitch": "^2.0.4" ··· 10297 "url": "https://opencollective.com/unified" 10298 } 10299 }, 10300 "node_modules/hast-util-to-jsx-runtime": { 10301 "version": "2.3.2", 10302 "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.2.tgz", ··· 10342 "trim-trailing-lines": "^2.0.0", 10343 "unist-util-position": "^5.0.0", 10344 "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 }, 10364 "funding": { 10365 "type": "opencollective", ··· 11291 "dependencies": { 11292 "jwa": "^1.4.1", 11293 "safe-buffer": "^5.0.1" 11294 } 11295 }, 11296 "node_modules/keyv": { ··· 13222 "wrappy": "1" 13223 } 13224 }, 13225 "node_modules/opener": { 13226 "version": "1.5.2", 13227 "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", ··· 14554 "url": "https://github.com/sponsors/ljharb" 14555 } 14556 }, 14557 "node_modules/regexp.prototype.flags": { 14558 "version": "1.5.4", 14559 "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", ··· 15229 "dev": true, 15230 "engines": { 15231 "node": ">=8" 15232 } 15233 }, 15234 "node_modules/side-channel": { ··· 16276 } 16277 }, 16278 "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==", 16282 "dev": true, 16283 "bin": { 16284 "tsc": "bin/tsc", 16285 "tsserver": "bin/tsserver"
··· 43 "fractional-indexing": "^3.2.0", 44 "hono": "^4.7.11", 45 "ioredis": "^5.6.1", 46 + "katex": "^0.16.22", 47 "linkifyjs": "^4.2.0", 48 "multiformats": "^13.3.2", 49 "next": "15.3.2", ··· 69 "remark-stringify": "^11.0.0", 70 "replicache": "^14.2.2", 71 "sharp": "^0.34.2", 72 + "shiki": "^3.8.1", 73 "swr": "^2.3.3", 74 "thumbhash": "^0.1.1", 75 "twilio": "^5.3.7", ··· 84 "@atproto/lex-cli": "^0.6.1", 85 "@atproto/lexicon": "^0.4.7", 86 "@cloudflare/workers-types": "^4.20240512.0", 87 + "@types/katex": "^0.16.7", 88 "@types/node": "^22.15.17", 89 "@types/react": "19.1.3", 90 "@types/react-dom": "19.1.3", ··· 99 "supabase": "^1.187.3", 100 "tailwindcss": "^3.4.3", 101 "tsx": "^4.19.3", 102 + "typescript": "^5.8.3", 103 "wrangler": "^3.56.0" 104 } 105 }, ··· 5674 "integrity": "sha512-qC/xYId4NMebE6w/V33Fh9gWxLgURiNYgVNObbJl2LZv0GUUItCcCqC5axQSwRaAgaxl2mELq1rMzlswaQ0Zxg==", 5675 "dev": true 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 + }, 5737 "node_modules/@supabase/auth-js": { 5738 "version": "2.64.2", 5739 "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.64.2.tgz", ··· 5963 "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", 5964 "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", 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" 5973 }, 5974 "node_modules/@types/linkify-it": { 5975 "version": "5.0.0", ··· 10280 "url": "https://opencollective.com/unified" 10281 } 10282 }, 10283 "node_modules/hast-util-to-estree": { 10284 "version": "3.1.0", 10285 "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.0.tgz", ··· 10321 } 10322 }, 10323 "node_modules/hast-util-to-html": { 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==", 10327 "dependencies": { 10328 "@types/hast": "^3.0.0", 10329 "@types/unist": "^3.0.0", 10330 "ccount": "^2.0.0", 10331 "comma-separated-tokens": "^2.0.0", 10332 "hast-util-whitespace": "^3.0.0", 10333 "html-void-elements": "^3.0.0", 10334 "mdast-util-to-hast": "^13.0.0", 10335 + "property-information": "^7.0.0", 10336 "space-separated-tokens": "^2.0.0", 10337 "stringify-entities": "^4.0.0", 10338 "zwitch": "^2.0.4" ··· 10342 "url": "https://opencollective.com/unified" 10343 } 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 + }, 10354 "node_modules/hast-util-to-jsx-runtime": { 10355 "version": "2.3.2", 10356 "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.2.tgz", ··· 10396 "trim-trailing-lines": "^2.0.0", 10397 "unist-util-position": "^5.0.0", 10398 "unist-util-visit": "^5.0.0" 10399 }, 10400 "funding": { 10401 "type": "opencollective", ··· 11327 "dependencies": { 11328 "jwa": "^1.4.1", 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" 11355 } 11356 }, 11357 "node_modules/keyv": { ··· 13283 "wrappy": "1" 13284 } 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 + }, 13301 "node_modules/opener": { 13302 "version": "1.5.2", 13303 "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", ··· 14630 "url": "https://github.com/sponsors/ljharb" 14631 } 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 + }, 14654 "node_modules/regexp.prototype.flags": { 14655 "version": "1.5.4", 14656 "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", ··· 15326 "dev": true, 15327 "engines": { 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" 15344 } 15345 }, 15346 "node_modules/side-channel": { ··· 16388 } 16389 }, 16390 "node_modules/typescript": { 16391 + "version": "5.8.3", 16392 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", 16393 + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", 16394 "dev": true, 16395 + "license": "Apache-2.0", 16396 "bin": { 16397 "tsc": "bin/tsc", 16398 "tsserver": "bin/tsserver"
+4 -1
package.json
··· 53 "fractional-indexing": "^3.2.0", 54 "hono": "^4.7.11", 55 "ioredis": "^5.6.1", 56 "linkifyjs": "^4.2.0", 57 "multiformats": "^13.3.2", 58 "next": "15.3.2", ··· 78 "remark-stringify": "^11.0.0", 79 "replicache": "^14.2.2", 80 "sharp": "^0.34.2", 81 "swr": "^2.3.3", 82 "thumbhash": "^0.1.1", 83 "twilio": "^5.3.7", ··· 92 "@atproto/lex-cli": "^0.6.1", 93 "@atproto/lexicon": "^0.4.7", 94 "@cloudflare/workers-types": "^4.20240512.0", 95 "@types/node": "^22.15.17", 96 "@types/react": "19.1.3", 97 "@types/react-dom": "19.1.3", ··· 106 "supabase": "^1.187.3", 107 "tailwindcss": "^3.4.3", 108 "tsx": "^4.19.3", 109 - "typescript": "^5.5.3", 110 "wrangler": "^3.56.0" 111 }, 112 "overrides": {
··· 53 "fractional-indexing": "^3.2.0", 54 "hono": "^4.7.11", 55 "ioredis": "^5.6.1", 56 + "katex": "^0.16.22", 57 "linkifyjs": "^4.2.0", 58 "multiformats": "^13.3.2", 59 "next": "15.3.2", ··· 79 "remark-stringify": "^11.0.0", 80 "replicache": "^14.2.2", 81 "sharp": "^0.34.2", 82 + "shiki": "^3.8.1", 83 "swr": "^2.3.3", 84 "thumbhash": "^0.1.1", 85 "twilio": "^5.3.7", ··· 94 "@atproto/lex-cli": "^0.6.1", 95 "@atproto/lexicon": "^0.4.7", 96 "@cloudflare/workers-types": "^4.20240512.0", 97 + "@types/katex": "^0.16.7", 98 "@types/node": "^22.15.17", 99 "@types/react": "19.1.3", 100 "@types/react-dom": "19.1.3", ··· 109 "supabase": "^1.187.3", 110 "tailwindcss": "^3.4.3", 111 "tsx": "^4.19.3", 112 + "typescript": "^5.8.3", 113 "wrangler": "^3.56.0" 114 }, 115 "overrides": {
+44 -41
src/hooks/useLongPress.ts
··· 1 - import { useRef, useEffect, useState, useCallback, useMemo } from "react"; 2 3 export const useLongPress = (cb: () => void, cancel?: boolean) => { 4 let longPressTimer = useRef<number>(undefined); 5 let isLongPress = useRef(false); 6 - let [startPosition, setStartPosition] = useState<{ 7 x: number; 8 y: number; 9 } | null>(null); 10 11 let onPointerDown = useCallback( 12 (e: React.MouseEvent) => { 13 if (e.button === 2) { 14 return; 15 } 16 // 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 - ); 26 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) => { 37 // Calculate the distance moved 38 const distance = Math.sqrt( 39 - Math.pow(e.clientX - startPosition.x, 2) + 40 - Math.pow(e.clientY - startPosition.y, 2), 41 ); 42 - // Only end if the distance is greater than 10 pixels 43 if (distance > 16) { 44 end(); 45 } 46 }; 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 - } 57 } 58 }; 59 - window.addEventListener("touchmove", touchListener); 60 61 - return () => { 62 - window.removeEventListener("mousemove", listener); 63 - window.removeEventListener("touchmove", touchListener); 64 - }; 65 - } 66 - }, [startPosition, end]); 67 68 let click = useCallback((e: React.MouseEvent | React.PointerEvent) => { 69 if (isLongPress.current) e.preventDefault();
··· 1 + import { useRef, useEffect, useCallback, useMemo } from "react"; 2 3 export const useLongPress = (cb: () => void, cancel?: boolean) => { 4 let longPressTimer = useRef<number>(undefined); 5 let isLongPress = useRef(false); 6 + let startPosition = useRef<{ 7 x: number; 8 y: number; 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 + }, []); 29 30 let onPointerDown = useCallback( 31 (e: React.MouseEvent) => { 32 + let el = e.target as HTMLElement; 33 + if (el.tagName === "SELECT") return; 34 if (e.button === 2) { 35 return; 36 } 37 // Set the starting position 38 + startPosition.current = { x: e.clientX, y: e.clientY }; 39 40 + // Add mousemove and touchmove listeners 41 + mouseMoveListener.current = (e: MouseEvent) => { 42 + if (!startPosition.current) return; 43 // Calculate the distance moved 44 const distance = Math.sqrt( 45 + Math.pow(e.clientX - startPosition.current.x, 2) + 46 + Math.pow(e.clientY - startPosition.current.y, 2), 47 ); 48 + // Only end if the distance is greater than 16 pixels 49 if (distance > 16) { 50 end(); 51 } 52 }; 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(); 62 } 63 }; 64 65 + window.addEventListener("mousemove", mouseMoveListener.current); 66 + window.addEventListener("touchmove", touchMoveListener.current); 67 + }, 68 + [cb, end], 69 + ); 70 71 let click = useCallback((e: React.MouseEvent | React.PointerEvent) => { 72 if (isLongPress.current) e.preventDefault();
+19 -1
src/replicache/attributes.ts
··· 83 type: "bluesky-post", 84 cardinality: "one", 85 }, 86 } as const; 87 88 const MailboxAttributes = { ··· 231 type: "color", 232 cardinality: "one", 233 }, 234 } as const; 235 236 export const Attributes = { ··· 313 | "embed" 314 | "button" 315 | "poll" 316 - | "bluesky-post"; 317 }; 318 "canvas-pattern-union": { 319 type: "canvas-pattern-union";
··· 83 type: "bluesky-post", 84 cardinality: "one", 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 + }, 98 } as const; 99 100 const MailboxAttributes = { ··· 243 type: "color", 244 cardinality: "one", 245 }, 246 + "theme/code-theme": { 247 + type: "string", 248 + cardinality: "one", 249 + }, 250 } as const; 251 252 export const Attributes = { ··· 329 | "embed" 330 | "button" 331 | "poll" 332 + | "bluesky-post" 333 + | "math" 334 + | "code"; 335 }; 336 "canvas-pattern-union": { 337 type: "canvas-pattern-union";
+40 -5
src/utils/focusBlock.ts
··· 5 6 import { useEditorStates } from "src/state/useEditorState"; 7 import { scrollIntoViewIfNeeded } from "./scrollIntoViewIfNeeded"; 8 9 export function focusBlock( 10 block: Pick<Block, "type" | "value" | "parent">, 11 position: Position, 12 ) { 13 // focus the block 14 - useUIState.getState().setSelectedBlock(block); 15 - useUIState.getState().setFocusedBlock({ 16 - entityType: "block", 17 - entityID: block.value, 18 - parent: block.parent, 19 }); 20 scrollIntoViewIfNeeded( 21 document.getElementById(elementId.block(block.value).container), 22 false, 23 ); 24 25 // if its not a text block, that's all we need to do 26 if (block.type !== "text" && block.type !== "heading") { ··· 44 break; 45 } 46 case "top": { 47 pos = nextBlock.view.posAtCoords({ 48 top: nextBlockViewClientRect.top + 12, 49 left: position.left, 50 }); 51 break; 52 } 53 case "bottom": {
··· 5 6 import { useEditorStates } from "src/state/useEditorState"; 7 import { scrollIntoViewIfNeeded } from "./scrollIntoViewIfNeeded"; 8 + import { getPosAtCoordinates } from "./getCoordinatesInTextarea"; 9 + import { flushSync } from "react-dom"; 10 11 export function focusBlock( 12 block: Pick<Block, "type" | "value" | "parent">, 13 position: Position, 14 ) { 15 // focus the block 16 + flushSync(() => { 17 + useUIState.getState().setSelectedBlock(block); 18 + useUIState.getState().setFocusedBlock({ 19 + entityType: "block", 20 + entityID: block.value, 21 + parent: block.parent, 22 + }); 23 }); 24 scrollIntoViewIfNeeded( 25 document.getElementById(elementId.block(block.value).container), 26 false, 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 + } 57 58 // if its not a text block, that's all we need to do 59 if (block.type !== "text" && block.type !== "heading") { ··· 77 break; 78 } 79 case "top": { 80 + console.log(position.left); 81 pos = nextBlock.view.posAtCoords({ 82 top: nextBlockViewClientRect.top + 12, 83 left: position.left, 84 }); 85 + console.log(pos); 86 break; 87 } 88 case "bottom": {
+26
src/utils/getBlocksAsHTML.tsx
··· 7 import { RenderYJSFragment } from "components/Blocks/TextBlock/RenderYJSFragment"; 8 import { Block } from "components/Blocks/Block"; 9 import { List, parseBlocksToList } from "./parseBlocksToList"; 10 11 export async function getBlocksAsHTML( 12 rep: Replicache<ReplicacheMutators>, ··· 75 ) { 76 let wrapper: undefined | "h1" | "h2" | "h3"; 77 let [alignment] = await scanIndex(tx).eav(b.value, "block/text-alignment"); 78 if (b.type === "image") { 79 let [src] = await scanIndex(tx).eav(b.value, "block/image"); 80 if (!src) return "";
··· 7 import { RenderYJSFragment } from "components/Blocks/TextBlock/RenderYJSFragment"; 8 import { Block } from "components/Blocks/Block"; 9 import { List, parseBlocksToList } from "./parseBlocksToList"; 10 + import Katex from "katex"; 11 12 export async function getBlocksAsHTML( 13 rep: Replicache<ReplicacheMutators>, ··· 76 ) { 77 let wrapper: undefined | "h1" | "h2" | "h3"; 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 + } 104 if (b.type === "image") { 105 let [src] = await scanIndex(tx).eav(b.value, "block/image"); 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 + }