From 67bb09ae90dde7748f3d7e09f9412fd3f057d4ff Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 23 Jul 2025 15:39:19 -0400 Subject: [PATCH] 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 --- actions/publishToPublication.ts | 45 +++- app/globals.css | 10 + .../[publication]/[rkey]/PostContent.tsx | 22 +- .../[publication]/[rkey]/StaticMathBlock.tsx | 20 ++ .../[rkey]/StaticPostContent.tsx | 21 +- app/lish/[did]/[publication]/rss/route.ts | 60 +++-- components/Blocks/BaseTextareaBlock.tsx | 60 +++++ components/Blocks/Block.tsx | 4 + components/Blocks/BlockCommands.tsx | 20 ++ components/Blocks/CodeBlock.tsx | 157 +++++++++++++ components/Blocks/MathBlock.tsx | 60 +++++ components/Blocks/TextBlock/inputRules.ts | 16 ++ components/Blocks/TextBlock/useHandlePaste.ts | 52 ++++- components/Blocks/useBlockKeyboardHandlers.ts | 1 + components/Blocks/useBlockMouseHandlers.ts | 3 + components/Icons/BlockCodeSmall.tsx | 19 ++ components/Icons/BlockMathSmall.tsx | 19 ++ components/SelectionManager.tsx | 6 +- components/utils/AutosizeTextarea.tsx | 47 ++-- components/utils/textarea-styles.module.css | 2 +- lexicons/api/index.ts | 4 + lexicons/api/lexicons.ts | 40 ++++ lexicons/api/types/pub/leaflet/blocks/code.ts | 28 +++ lexicons/api/types/pub/leaflet/blocks/math.ts | 26 +++ .../types/pub/leaflet/pages/linearDocument.ts | 4 + lexicons/pub/leaflet/blocks/code.json | 23 ++ lexicons/pub/leaflet/blocks/math.json | 17 ++ .../pub/leaflet/pages/linearDocument.json | 4 +- lexicons/src/blocks.ts | 31 +++ package-lock.json | 215 +++++++++++++----- package.json | 5 +- src/hooks/useLongPress.ts | 85 +++---- src/replicache/attributes.ts | 20 +- src/utils/focusBlock.ts | 45 +++- src/utils/getBlocksAsHTML.tsx | 26 +++ src/utils/getCoordinatesInTextarea.ts | 130 +++++++++++ 36 files changed, 1189 insertions(+), 158 deletions(-) create mode 100644 app/lish/[did]/[publication]/[rkey]/StaticMathBlock.tsx create mode 100644 components/Blocks/BaseTextareaBlock.tsx create mode 100644 components/Blocks/CodeBlock.tsx create mode 100644 components/Blocks/MathBlock.tsx create mode 100644 components/Icons/BlockCodeSmall.tsx create mode 100644 components/Icons/BlockMathSmall.tsx create mode 100644 lexicons/api/types/pub/leaflet/blocks/code.ts create mode 100644 lexicons/api/types/pub/leaflet/blocks/math.ts create mode 100644 lexicons/pub/leaflet/blocks/code.json create mode 100644 lexicons/pub/leaflet/blocks/math.json create mode 100644 src/utils/getCoordinatesInTextarea.ts diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index 1df08e10..fec78121 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -14,6 +14,8 @@ import { PubLeafletPagesLinearDocument, PubLeafletRichtextFacet, PubLeafletBlocksWebsite, + PubLeafletBlocksCode, + PubLeafletBlocksMath, } from "lexicons/api"; import { Block } from "components/Blocks/Block"; import { TID } from "@atproto/common"; @@ -95,6 +97,7 @@ export async function publishToPublication({ blocks, imageMap, scan, + root_entity, ); let existingRecord = @@ -149,6 +152,7 @@ function blocksToRecord( blocks: Block[], imageMap: Map, scan: ReturnType, + root_entity: string, ): PubLeafletPagesLinearDocument.Block[] { let parsedBlocks = parseBlocksToList(blocks); return parsedBlocks.flatMap((blockOrList) => { @@ -162,7 +166,7 @@ function blocksToRecord( : alignmentValue === "right" ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" : undefined; - let b = blockToRecord(blockOrList.block, imageMap, scan); + let b = blockToRecord(blockOrList.block, imageMap, scan, root_entity); if (!b) return []; let block: PubLeafletPagesLinearDocument.Block = { $type: "pub.leaflet.pages.linearDocument#block", @@ -175,7 +179,12 @@ function blocksToRecord( $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.unorderedList", - children: childrenToRecord(blockOrList.children, imageMap, scan), + children: childrenToRecord( + blockOrList.children, + imageMap, + scan, + root_entity, + ), }, }; return [block]; @@ -187,14 +196,15 @@ function childrenToRecord( children: List[], imageMap: Map, scan: ReturnType, + root_entity: string, ) { return children.flatMap((child) => { - let content = blockToRecord(child.block, imageMap, scan); + let content = blockToRecord(child.block, imageMap, scan, root_entity); if (!content) return []; let record: PubLeafletBlocksUnorderedList.ListItem = { $type: "pub.leaflet.blocks.unorderedList#listItem", content, - children: childrenToRecord(child.children, imageMap, scan), + children: childrenToRecord(child.children, imageMap, scan, root_entity), }; return record; }); @@ -203,6 +213,7 @@ function blockToRecord( b: Block, imageMap: Map, scan: ReturnType, + root_entity: string, ) { const getBlockContent = (b: string) => { let [content] = scan.eav(b, "block/text"); @@ -219,11 +230,11 @@ function blockToRecord( b.type !== "text" && b.type !== "heading" && b.type !== "image" && - b.type !== "link" + b.type !== "link" && + b.type !== "code" && + b.type !== "math" ) return; - let alignmentValue = - scan.eav(b.value, "block/text-alignment")[0]?.data.value || "left"; if (b.type === "heading") { let [headingLevel] = scan.eav(b.value, "block/heading-level"); @@ -282,6 +293,26 @@ function blockToRecord( }; return block; } + if (b.type === "code") { + let [language] = scan.eav(b.value, "block/code-language"); + let [code] = scan.eav(b.value, "block/code"); + let [theme] = scan.eav(root_entity, "theme/code-theme"); + let block: $Typed = { + $type: "pub.leaflet.blocks.code", + language: language?.data.value, + plaintext: code?.data.value || "", + syntaxHighlightingTheme: theme?.data.value, + }; + return block; + } + if (b.type === "math") { + let [math] = scan.eav(b.value, "block/math"); + let block: $Typed = { + $type: "pub.leaflet.blocks.math", + tex: math?.data.value || "", + }; + return block; + } return; } diff --git a/app/globals.css b/app/globals.css index 4083c3b9..b57ea580 100644 --- a/app/globals.css +++ b/app/globals.css @@ -157,6 +157,16 @@ input[type="number"] { ); } +pre.shiki code { + display: block; +} + +pre.shiki { + @apply p-2; + @apply rounded-md; + @apply overflow-auto; +} + .highlight { @apply px-[1px]; @apply py-[1px]; diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index 96faf41a..d2a84980 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -1,4 +1,6 @@ import { + PubLeafletBlocksMath, + PubLeafletBlocksCode, PubLeafletBlocksHeader, PubLeafletBlocksImage, PubLeafletBlocksText, @@ -12,6 +14,9 @@ import { TextBlock } from "./TextBlock"; import { Popover } from "components/Popover"; import { theme } from "tailwind.config"; import { ImageAltSmall } from "components/Icons/ImageAlt"; +import { codeToHtml } from "shiki"; +import Katex from "katex"; +import { StaticMathBlock } from "./StaticMathBlock"; export function PostContent({ blocks, @@ -29,7 +34,7 @@ export function PostContent({ ); } -let Block = ({ +let Block = async ({ block, did, isList, @@ -71,6 +76,21 @@ let Block = ({ ); } + case PubLeafletBlocksMath.isMain(b.block): { + return ; + } + case PubLeafletBlocksCode.isMain(b.block): { + let html = await codeToHtml(b.block.plaintext, { + lang: b.block.language || "plaintext", + theme: b.block.syntaxHighlightingTheme || "github-light", + }); + return ( +
+ ); + } case PubLeafletBlocksWebsite.isMain(b.block): { return ( { + const html = Katex.renderToString(block.tex, { + displayMode: true, + output: "html", + throwOnError: false, + }); + return ( +
+
+
+ ); +}; diff --git a/app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx b/app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx index 1b9083c5..48345736 100644 --- a/app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx @@ -1,6 +1,8 @@ import { + PubLeafletBlocksCode, PubLeafletBlocksHeader, PubLeafletBlocksImage, + PubLeafletBlocksMath, PubLeafletBlocksText, PubLeafletBlocksUnorderedList, PubLeafletBlocksWebsite, @@ -9,6 +11,8 @@ import { } from "lexicons/api"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { TextBlock } from "./TextBlock"; +import { StaticMathBlock } from "./StaticMathBlock"; +import { codeToHtml } from "shiki"; export function StaticPostContent({ blocks, @@ -26,7 +30,7 @@ export function StaticPostContent({ ); } -let Block = ({ +let Block = async ({ block, did, isList, @@ -38,6 +42,21 @@ let Block = ({ let b = block; switch (true) { + case PubLeafletBlocksMath.isMain(b.block): { + return ; + } + case PubLeafletBlocksCode.isMain(b.block): { + let html = await codeToHtml(b.block.plaintext, { + lang: b.block.language || "plaintext", + theme: b.block.syntaxHighlightingTheme || "github-light", + }); + return ( +
+ ); + } case PubLeafletBlocksUnorderedList.isMain(b.block): { return (
    diff --git a/app/lish/[did]/[publication]/rss/route.ts b/app/lish/[did]/[publication]/rss/route.ts index 95e0ba91..c81d4415 100644 --- a/app/lish/[did]/[publication]/rss/route.ts +++ b/app/lish/[did]/[publication]/rss/route.ts @@ -18,8 +18,8 @@ export async function GET( params: Promise<{ publication: string; did: string }>; }, ) { - let renderToStaticMarkup = await import("react-dom/server").then( - (module) => module.renderToStaticMarkup, + let renderToReadableStream = await import("react-dom/server").then( + (module) => module.renderToReadableStream, ); let params = await props.params; let { result: publication } = await get_publication_data.handler( @@ -46,28 +46,42 @@ export async function GET( }, }); - publication?.documents_in_publications.forEach((doc) => { - if (!doc.documents) return; - let record = doc.documents?.data as PubLeafletDocument.Record; - let uri = new AtUri(doc.documents?.uri); - let rkey = uri.rkey; - if (!record) return; - let firstPage = record.pages[0]; - let blocks: PubLeafletPagesLinearDocument.Block[] = []; - if (PubLeafletPagesLinearDocument.isMain(firstPage)) { - blocks = firstPage.blocks || []; - } - feed.addItem({ - title: record.title, - description: record.description, - date: record.publishedAt ? new Date(record.publishedAt) : new Date(), - id: `https://${pubRecord.base_path}/${rkey}`, - link: `https://${pubRecord.base_path}/${rkey}`, - content: renderToStaticMarkup( + await Promise.all( + publication?.documents_in_publications.map(async (doc) => { + if (!doc.documents) return; + let record = doc.documents?.data as PubLeafletDocument.Record; + let uri = new AtUri(doc.documents?.uri); + let rkey = uri.rkey; + if (!record) return; + let firstPage = record.pages[0]; + let blocks: PubLeafletPagesLinearDocument.Block[] = []; + if (PubLeafletPagesLinearDocument.isMain(firstPage)) { + blocks = firstPage.blocks || []; + } + let stream = await renderToReadableStream( createElement(StaticPostContent, { blocks, did: uri.host }), - ), - }); - }); + ); + const reader = stream.getReader(); + const chunks = []; + + let done, value; + while (!done) { + ({ done, value } = await reader.read()); + if (value) { + chunks.push(new TextDecoder().decode(value)); + } + } + + feed.addItem({ + title: record.title, + description: record.description, + date: record.publishedAt ? new Date(record.publishedAt) : new Date(), + id: `https://${pubRecord.base_path}/${rkey}`, + link: `https://${pubRecord.base_path}/${rkey}`, + content: chunks.join(""), + }); + }), + ); return new Response(feed.rss2(), { headers: { "Content-Type": "text/xml", diff --git a/components/Blocks/BaseTextareaBlock.tsx b/components/Blocks/BaseTextareaBlock.tsx new file mode 100644 index 00000000..15e251bd --- /dev/null +++ b/components/Blocks/BaseTextareaBlock.tsx @@ -0,0 +1,60 @@ +import { + AsyncValueAutosizeTextarea, + AutosizeTextareaProps, +} from "components/utils/AutosizeTextarea"; +import { BlockProps } from "./Block"; +import { getCoordinatesInTextarea } from "src/utils/getCoordinatesInTextarea"; +import { focusBlock } from "src/utils/focusBlock"; + +export function BaseTextareaBlock( + props: AutosizeTextareaProps & { + block: Pick; + }, +) { + let { block, ...passDownProps } = props; + return ( + { + if (e.key === "ArrowUp") { + let selection = e.currentTarget.selectionStart; + + let lastLineBeforeCursor = e.currentTarget.value + .slice(0, selection) + .lastIndexOf("\n"); + if (lastLineBeforeCursor !== -1) return; + let block = props.block.previousBlock; + let coord = getCoordinatesInTextarea(e.currentTarget, selection); + if (block) { + focusBlock(block, { + left: coord.left + e.currentTarget.getBoundingClientRect().left, + type: "bottom", + }); + return true; + } + } + if (e.key === "ArrowDown") { + let selection = e.currentTarget.selectionStart; + + let lastLine = e.currentTarget.value.lastIndexOf("\n"); + let lastLineBeforeCursor = e.currentTarget.value + .slice(0, selection) + .lastIndexOf("\n"); + if (lastLine !== lastLineBeforeCursor) return; + e.preventDefault(); + let block = props.block.nextBlock; + + let coord = getCoordinatesInTextarea(e.currentTarget, selection); + console.log(coord); + if (block) { + focusBlock(block, { + left: coord.left + e.currentTarget.getBoundingClientRect().left, + type: "top", + }); + return true; + } + } + }} + /> + ); +} diff --git a/components/Blocks/Block.tsx b/components/Blocks/Block.tsx index 57b18831..2e03b380 100644 --- a/components/Blocks/Block.tsx +++ b/components/Blocks/Block.tsx @@ -26,6 +26,8 @@ import { BlueskyPostBlock } from "./BlueskyPostBlock"; import { CheckboxChecked } from "components/Icons/CheckboxChecked"; import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; import { LockTiny } from "components/Icons/LockTiny"; +import { MathBlock } from "./MathBlock"; +import { CodeBlock } from "./CodeBlock"; export type Block = { factID: string; @@ -168,6 +170,8 @@ const BlockTypeComponents: { BlockProps & { preview?: boolean } >; } = { + code: CodeBlock, + math: MathBlock, card: PageLinkBlock, text: TextBlock, heading: TextBlock, diff --git a/components/Blocks/BlockCommands.tsx b/components/Blocks/BlockCommands.tsx index 59af4606..4600f2d6 100644 --- a/components/Blocks/BlockCommands.tsx +++ b/components/Blocks/BlockCommands.tsx @@ -29,6 +29,8 @@ import { import { LinkSmall } from "components/Icons/LinkSmall"; import { BlockRSVPSmall } from "components/Icons/BlockRSVPSmall"; import { ListUnorderedSmall } from "components/Toolbar/ListToolbar"; +import { BlockMathSmall } from "components/Icons/BlockMathSmall"; +import { BlockCodeSmall } from "components/Icons/BlockCodeSmall"; type Props = { parent: string; @@ -267,6 +269,24 @@ export const blockCommands: Command[] = [ createBlockWithType(rep, props, "bluesky-post"); }, }, + { + name: "Math", + icon: , + type: "block", + hiddenInPublication: false, + onSelect: async (rep, props) => { + createBlockWithType(rep, props, "math"); + }, + }, + { + name: "Code", + icon: , + type: "block", + hiddenInPublication: false, + onSelect: async (rep, props) => { + createBlockWithType(rep, props, "code"); + }, + }, // EVENT STUFF { diff --git a/components/Blocks/CodeBlock.tsx b/components/Blocks/CodeBlock.tsx new file mode 100644 index 00000000..57f900aa --- /dev/null +++ b/components/Blocks/CodeBlock.tsx @@ -0,0 +1,157 @@ +import { + BundledLanguage, + bundledLanguagesInfo, + bundledThemesInfo, + codeToHtml, +} from "shiki"; +import { useEntity, useReplicache } from "src/replicache"; +import "katex/dist/katex.min.css"; +import { BlockProps } from "./Block"; +import { useCallback, useLayoutEffect, useMemo, useState } from "react"; +import { useUIState } from "src/useUIState"; +import { BaseTextareaBlock } from "./BaseTextareaBlock"; +import { useEntitySetContext } from "components/EntitySetProvider"; +import { flushSync } from "react-dom"; +import { elementId } from "src/utils/elementId"; + +export function CodeBlock(props: BlockProps) { + let { rep, rootEntity } = useReplicache(); + let content = useEntity(props.entityID, "block/code"); + let lang = + useEntity(props.entityID, "block/code-language")?.data.value || "plaintext"; + + let theme = + useEntity(rootEntity, "theme/code-theme")?.data.value || "github-light"; + let focusedBlock = useUIState( + (s) => s.focusedEntity?.entityID === props.entityID, + ); + let { permissions } = useEntitySetContext(); + const [html, setHTML] = useState(null); + + useLayoutEffect(() => { + if (!content) return; + void codeToHtml(content.data.value, { + lang, + theme, + structure: "classic", + }).then((h) => { + setHTML(h.replaceAll("
    ", "\n")); + }); + }, [content, lang, theme]); + + const onClick = useCallback((e: React.MouseEvent) => { + let selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return; + let range = selection.getRangeAt(0); + if (!range) return; + let length = range.toString().length; + range.setStart(e.currentTarget, 0); + let end = range.toString().length; + let start = end - length; + + flushSync(() => { + useUIState.getState().setSelectedBlock(props); + useUIState.getState().setFocusedBlock({ + entityType: "block", + entityID: props.value, + parent: props.parent, + }); + }); + let el = document.getElementById( + elementId.block(props.entityID).input, + ) as HTMLTextAreaElement; + if (!el) return; + el.focus(); + el.setSelectionRange(start, end); + }, []); + return ( +
    + {permissions.write && ( +
    +
    + Theme:{" "} + +
    + +
    + )} +
    + {focusedBlock && permissions.write ? ( + { + // Update the entity with the new value + await rep?.mutate.assertFact({ + attribute: "block/code", + entity: props.entityID, + data: { type: "string", value: e.target.value }, + }); + }} + /> + ) : !html ? ( +
     e.stopPropagation()}
    +            className="codeBlockRendered !overflow-auto font-mono p-2 w-full h-full"
    +          >
    +            {content?.data.value}
    +          
    + ) : ( +
    e.stopPropagation()} + onClick={onClick} + data-lang={lang} + className="contents" + dangerouslySetInnerHTML={{ __html: html || "" }} + /> + )} +
    +
    + ); +} diff --git a/components/Blocks/MathBlock.tsx b/components/Blocks/MathBlock.tsx new file mode 100644 index 00000000..f25879ca --- /dev/null +++ b/components/Blocks/MathBlock.tsx @@ -0,0 +1,60 @@ +import { useEntity, useReplicache } from "src/replicache"; +import "katex/dist/katex.min.css"; +import { BlockProps } from "./Block"; +import Katex from "katex"; +import { useMemo } from "react"; +import { useUIState } from "src/useUIState"; +import { theme } from "tailwind.config"; +import { BaseTextareaBlock } from "./BaseTextareaBlock"; +import { elementId } from "src/utils/elementId"; + +export function MathBlock(props: BlockProps) { + let content = useEntity(props.entityID, "block/math"); + let focusedBlock = useUIState( + (s) => s.focusedEntity?.entityID === props.entityID, + ); + let { rep } = useReplicache(); + const { html, error } = useMemo(() => { + try { + const html = Katex.renderToString(content?.data.value || "", { + displayMode: true, + throwOnError: false, + errorColor: theme.colors["accent-contrast"], + }); + + return { html, error: undefined }; + } catch (error) { + if (error instanceof Katex.ParseError || error instanceof TypeError) { + return { error }; + } + + throw error; + } + }, [content?.data.value]); + return focusedBlock ? ( + { + // Update the entity with the new value + await rep?.mutate.assertFact({ + attribute: "block/math", + entity: props.entityID, + data: { type: "string", value: e.target.value }, + }); + }} + /> + ) : html && content?.data.value ? ( +
    + ) : ( +
    + write some Tex here... +
    + ); +} diff --git a/components/Blocks/TextBlock/inputRules.ts b/components/Blocks/TextBlock/inputRules.ts index c8956db1..e1baad11 100644 --- a/components/Blocks/TextBlock/inputRules.ts +++ b/components/Blocks/TextBlock/inputRules.ts @@ -10,6 +10,7 @@ import { BlockProps } from "../Block"; import { focusBlock } from "src/utils/focusBlock"; import { schema } from "./schema"; import { useUIState } from "src/useUIState"; +import { flushSync } from "react-dom"; export const inputrules = ( propsRef: MutableRefObject, repRef: MutableRefObject | null>, @@ -87,6 +88,21 @@ export const inputrules = ( return null; }), + // Code Block + new InputRule(/^```\s$/, (state, match) => { + flushSync(() => + repRef.current?.mutate.assertFact({ + entity: propsRef.current.entityID, + attribute: "block/type", + data: { type: "block-type-union", value: "code" }, + }), + ); + setTimeout(() => { + focusBlock({ ...propsRef.current, type: "code" }, { type: "start" }); + }, 20); + return null; + }), + //Checklist new InputRule(/^\-?\[(\ |x)?\]\s$/, (state, match) => { if (!propsRef.current.listData) diff --git a/components/Blocks/TextBlock/useHandlePaste.ts b/components/Blocks/TextBlock/useHandlePaste.ts index 4055c18e..78d183bc 100644 --- a/components/Blocks/TextBlock/useHandlePaste.ts +++ b/components/Blocks/TextBlock/useHandlePaste.ts @@ -208,6 +208,10 @@ const createBlockFromHTML = ( type = "text"; break; } + case "PRE": { + type = "code"; + break; + } case "P": { type = "text"; break; @@ -312,6 +316,35 @@ const createBlockFromHTML = ( } } } + if (child.tagName === "PRE") { + let lang = child.getAttribute("data-language") || "plaintext"; + if (child.firstElementChild && child.firstElementChild.className) { + let className = child.firstElementChild.className; + let match = className.match(/language-(\w+)/); + if (match) { + lang = match[1]; + } + } + if (child.textContent) { + rep.mutate.assertFact([ + { + entity: entityID, + attribute: "block/type", + data: { type: "block-type-union", value: "code" }, + }, + { + entity: entityID, + attribute: "block/code-language", + data: { type: "string", value: lang }, + }, + { + entity: entityID, + attribute: "block/code", + data: { type: "string", value: child.textContent }, + }, + ]); + } + } if (child.tagName === "IMG") { let src = child.getAttribute("src"); if (src) { @@ -326,6 +359,21 @@ const createBlockFromHTML = ( }); } } + if (child.tagName === "DIV" && child.getAttribute("data-tex")) { + let tex = child.getAttribute("data-tex"); + rep.mutate.assertFact([ + { + entity: entityID, + attribute: "block/type", + data: { type: "block-type-union", value: "math" }, + }, + { + entity: entityID, + attribute: "block/math", + data: { type: "string", value: tex || "" }, + }, + ]); + } if (child.tagName === "DIV" && child.getAttribute("data-entityid")) { let oldEntityID = child.getAttribute("data-entityid") as string; @@ -503,6 +551,7 @@ function flattenHTMLToTextBlocks(element: HTMLElement): HTMLElement[] { if ( [ "P", + "PRE", "H1", "H2", "H3", @@ -515,7 +564,8 @@ function flattenHTMLToTextBlocks(element: HTMLElement): HTMLElement[] { "A", "SPAN", ].includes(elementNode.tagName) || - elementNode.getAttribute("data-entityid") + elementNode.getAttribute("data-entityid") || + elementNode.getAttribute("data-tex") ) { htmlBlocks.push(elementNode); } else { diff --git a/components/Blocks/useBlockKeyboardHandlers.ts b/components/Blocks/useBlockKeyboardHandlers.ts index 95620cef..ba0238ba 100644 --- a/components/Blocks/useBlockKeyboardHandlers.ts +++ b/components/Blocks/useBlockKeyboardHandlers.ts @@ -53,6 +53,7 @@ export function useBlockKeyboardHandlers( (el.tagName === "LABEL" || el.tagName === "INPUT" || el.tagName === "TEXTAREA" || + el.tagName === "SELECT" || el.contentEditable === "true") && !isTextBlock[props.type] ) { diff --git a/components/Blocks/useBlockMouseHandlers.ts b/components/Blocks/useBlockMouseHandlers.ts index d33a0d0f..392024de 100644 --- a/components/Blocks/useBlockMouseHandlers.ts +++ b/components/Blocks/useBlockMouseHandlers.ts @@ -18,6 +18,8 @@ export function useBlockMouseHandlers(props: Block) { (e: MouseEvent) => { if ((e.target as Element).getAttribute("data-draggable")) return; if ((e.target as Element).tagName === "BUTTON") return; + if ((e.target as Element).tagName === "SELECT") return; + if ((e.target as Element).tagName === "OPTION") return; if (isMobile) return; if (!entity_set.permissions.write) return; useSelectingMouse.setState({ start: props.value }); @@ -30,6 +32,7 @@ export function useBlockMouseHandlers(props: Block) { e.preventDefault(); useUIState.getState().addBlockToSelection(props); } else { + if (e.isDefaultPrevented()) return; useUIState.getState().setFocusedBlock({ entityType: "block", entityID: props.value, diff --git a/components/Icons/BlockCodeSmall.tsx b/components/Icons/BlockCodeSmall.tsx new file mode 100644 index 00000000..4492fe61 --- /dev/null +++ b/components/Icons/BlockCodeSmall.tsx @@ -0,0 +1,19 @@ +import { Props } from "./Props"; + +export const BlockCodeSmall = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/Icons/BlockMathSmall.tsx b/components/Icons/BlockMathSmall.tsx new file mode 100644 index 00000000..c5b2a7b2 --- /dev/null +++ b/components/Icons/BlockMathSmall.tsx @@ -0,0 +1,19 @@ +import { Props } from "./Props"; + +export const BlockMathSmall = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/SelectionManager.tsx b/components/SelectionManager.tsx index 0808a8bb..82e58d03 100644 --- a/components/SelectionManager.tsx +++ b/components/SelectionManager.tsx @@ -514,6 +514,7 @@ export function SelectionManager() { savedSelection.current = null; if ( initialContentEditableParent.current && + !(e.target as Element).getAttribute("data-draggable") && getContentEditableParent(e.target as Node) !== initialContentEditableParent.current ) { @@ -617,7 +618,10 @@ export function restoreSelection(savedRanges: SavedRange[]) { function getContentEditableParent(e: Node | null): Node | null { let element: Node | null = e; while (element && element !== document) { - if ((element as HTMLElement).contentEditable === "true") { + if ( + (element as HTMLElement).contentEditable === "true" || + (element as HTMLElement).getAttribute("data-editable-block") + ) { return element; } element = element.parentNode; diff --git a/components/utils/AutosizeTextarea.tsx b/components/utils/AutosizeTextarea.tsx index 92e51895..ec88bb4f 100644 --- a/components/utils/AutosizeTextarea.tsx +++ b/components/utils/AutosizeTextarea.tsx @@ -7,36 +7,37 @@ import { } from "react"; import styles from "./textarea-styles.module.css"; -type Props = React.DetailedHTMLProps< +export type AutosizeTextareaProps = React.DetailedHTMLProps< React.TextareaHTMLAttributes, HTMLTextAreaElement >; -export const AutosizeTextarea = forwardRef( - (props: Props, ref) => { - let textarea = useRef(null); - useImperativeHandle(ref, () => textarea.current as HTMLTextAreaElement); +export const AutosizeTextarea = forwardRef< + HTMLTextAreaElement, + AutosizeTextareaProps +>((props: AutosizeTextareaProps, ref) => { + let textarea = useRef(null); + useImperativeHandle(ref, () => textarea.current as HTMLTextAreaElement); - return ( -
    -