From 75c5cf5423eb8b447e2989ebfaa782bbc95c42a4 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Mon, 11 Aug 2025 22:21:19 +0000 Subject: [PATCH] Feature/blockquote (#177) * add block quote blocktype * handle copying and pasting blockquotes --- app/globals.css | 4 ++++ components/Blocks/Block.tsx | 1 + components/Blocks/BlockCommands.tsx | 12 ++++++++++++ components/Icons/QuoteSmall.tsx | 19 +++++++++++++++++++ components/Toolbar/index.tsx | 6 +++++- lexicons/api/index.ts | 2 ++ lexicons/api/lexicons.ts | 24 ++++++++++++++++++++++++ lexicons/src/blocks.ts | 19 +++++++++++++++++++ src/replicache/attributes.ts | 1 + src/utils/focusBlock.ts | 19 ++++++++++++++----- src/utils/getBlocksAsHTML.tsx | 25 +++++++++++++++++++++++++ supabase/migrations/20250809164020_add_comments_table.sql | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ components/Blocks/TextBlock/index.tsx | 23 +++++++++++++++++------ components/Blocks/TextBlock/inputRules.ts | 12 ++++++++++++ components/Blocks/TextBlock/keymap.ts | 8 +++++++- components/Blocks/TextBlock/useHandlePaste.ts | 15 ++++++++++++--- lexicons/pub/leaflet/blocks/blockquote.json | 24 ++++++++++++++++++++++++ lexicons/pub/leaflet/pages/linearDocument.json | 1 + app/lish/[did]/[publication]/[rkey]/PostContent.tsx | 16 ++++++++++++++++ lexicons/api/types/pub/leaflet/blocks/blockquote.ts | 28 ++++++++++++++++++++++++++++ lexicons/api/types/pub/leaflet/pages/linearDocument.ts | 2 ++ 21 file(s) changed, 313 insertion(s)(+), 16 deletion(s)(-) diff --git a/app/globals.css b/app/globals.css --- a/app/globals.css +++ b/app/globals.css @@ -128,6 +128,10 @@ /* END GLOBAL STYLING */ } +blockquote { + margin: 0; +} + /* Hide scrollbar for Chrome, Safari and Opera */ .no-scrollbar::-webkit-scrollbar { display: none; diff --git a/components/Blocks/Block.tsx b/components/Blocks/Block.tsx --- a/components/Blocks/Block.tsx +++ b/components/Blocks/Block.tsx @@ -175,6 +175,7 @@ math: MathBlock, card: PageLinkBlock, text: TextBlock, + blockquote: TextBlock, heading: TextBlock, image: ImageBlock, link: ExternalLinkBlock, diff --git a/components/Blocks/BlockCommands.tsx b/components/Blocks/BlockCommands.tsx --- a/components/Blocks/BlockCommands.tsx +++ b/components/Blocks/BlockCommands.tsx @@ -31,6 +31,8 @@ import { ListUnorderedSmall } from "components/Toolbar/ListToolbar"; import { BlockMathSmall } from "components/Icons/BlockMathSmall"; import { BlockCodeSmall } from "components/Icons/BlockCodeSmall"; +import { QuoteTiny } from "components/Icons/QuoteTiny"; +import { QuoteSmall } from "components/Icons/QuoteSmall"; type Props = { parent: string; @@ -155,6 +157,16 @@ attribute: "block/is-list", data: { value: true, type: "boolean" }, }); + clearCommandSearchText(entity); + }, + }, + { + name: "Block Quote", + icon: , + type: "text", + onSelect: async (rep, props, um) => { + if (props.entityID) clearCommandSearchText(props.entityID); + let entity = await createBlockWithType(rep, props, "blockquote"); clearCommandSearchText(entity); }, }, diff --git a/components/Icons/QuoteSmall.tsx b/components/Icons/QuoteSmall.tsx new file mode 100644 --- /dev/null +++ b/components/Icons/QuoteSmall.tsx @@ -0,0 +1,19 @@ +import { Props } from "./Props"; + +export const QuoteSmall = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/Toolbar/index.tsx b/components/Toolbar/index.tsx --- a/components/Toolbar/index.tsx +++ b/components/Toolbar/index.tsx @@ -65,7 +65,11 @@ useEffect(() => { if (!blockType) return; - if (blockType !== "heading" && blockType !== "text") { + if ( + blockType !== "heading" && + blockType !== "text" && + blockType !== "blockquote" + ) { setToolbarState("block"); } else { setToolbarState("default"); diff --git a/lexicons/api/index.ts b/lexicons/api/index.ts --- a/lexicons/api/index.ts +++ b/lexicons/api/index.ts @@ -7,6 +7,7 @@ import { OmitKey, Un$Typed } from './util' import * as PubLeafletDocument from './types/pub/leaflet/document' import * as PubLeafletPublication from './types/pub/leaflet/publication' +import * as PubLeafletBlocksBlockquote from './types/pub/leaflet/blocks/blockquote' import * as PubLeafletBlocksCode from './types/pub/leaflet/blocks/code' import * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header' import * as PubLeafletBlocksHorizontalRule from './types/pub/leaflet/blocks/horizontalRule' @@ -37,6 +38,7 @@ export * as PubLeafletDocument from './types/pub/leaflet/document' export * as PubLeafletPublication from './types/pub/leaflet/publication' +export * as PubLeafletBlocksBlockquote from './types/pub/leaflet/blocks/blockquote' export * as PubLeafletBlocksCode from './types/pub/leaflet/blocks/code' export * as PubLeafletBlocksHeader from './types/pub/leaflet/blocks/header' export * as PubLeafletBlocksHorizontalRule from './types/pub/leaflet/blocks/horizontalRule' diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -161,6 +161,28 @@ }, }, }, + PubLeafletBlocksBlockquote: { + lexicon: 1, + id: 'pub.leaflet.blocks.blockquote', + defs: { + main: { + type: 'object', + required: ['plaintext'], + properties: { + plaintext: { + type: 'string', + }, + facets: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:pub.leaflet.richtext.facet', + }, + }, + }, + }, + }, + }, PubLeafletBlocksCode: { lexicon: 1, id: 'pub.leaflet.blocks.code', @@ -407,6 +429,7 @@ type: 'union', refs: [ 'lex:pub.leaflet.blocks.text', + 'lex:pub.leaflet.blocks.blockquote', 'lex:pub.leaflet.blocks.header', 'lex:pub.leaflet.blocks.image', 'lex:pub.leaflet.blocks.unorderedList', @@ -1661,6 +1684,7 @@ export const ids = { PubLeafletDocument: 'pub.leaflet.document', PubLeafletPublication: 'pub.leaflet.publication', + PubLeafletBlocksBlockquote: 'pub.leaflet.blocks.blockquote', PubLeafletBlocksCode: 'pub.leaflet.blocks.code', PubLeafletBlocksHeader: 'pub.leaflet.blocks.header', PubLeafletBlocksHorizontalRule: 'pub.leaflet.blocks.horizontalRule', diff --git a/lexicons/src/blocks.ts b/lexicons/src/blocks.ts --- a/lexicons/src/blocks.ts +++ b/lexicons/src/blocks.ts @@ -19,6 +19,24 @@ }, }; +export const PubLeafletBlocksBlockQuote: LexiconDoc = { + lexicon: 1, + id: "pub.leaflet.blocks.blockquote", + defs: { + main: { + type: "object", + required: ["plaintext"], + properties: { + plaintext: { type: "string" }, + facets: { + type: "array", + items: { type: "ref", ref: PubLeafletRichTextFacet.id }, + }, + }, + }, + }, +}; + export const PubLeafletBlocksHorizontalRule: LexiconDoc = { lexicon: 1, id: "pub.leaflet.blocks.horizontalRule", @@ -175,6 +193,7 @@ }; export const BlockLexicons = [ PubLeafletBlocksText, + PubLeafletBlocksBlockQuote, PubLeafletBlocksHeader, PubLeafletBlocksImage, PubLeafletBlocksUnorderedList, diff --git a/src/replicache/attributes.ts b/src/replicache/attributes.ts --- a/src/replicache/attributes.ts +++ b/src/replicache/attributes.ts @@ -332,6 +332,7 @@ | "bluesky-post" | "math" | "code" + | "blockquote" | "horizontal-rule"; }; "canvas-pattern-union": { diff --git a/src/utils/focusBlock.ts b/src/utils/focusBlock.ts --- a/src/utils/focusBlock.ts +++ b/src/utils/focusBlock.ts @@ -1,4 +1,4 @@ -import { TextSelection } from "prosemirror-state"; +import { NodeSelection, TextSelection } from "prosemirror-state"; import { useUIState } from "src/useUIState"; import { Block } from "components/Blocks/Block"; import { elementId } from "src/utils/elementId"; @@ -56,7 +56,11 @@ } // if its not a text block, that's all we need to do - if (block.type !== "text" && block.type !== "heading") { + if ( + block.type !== "text" && + block.type !== "heading" && + block.type !== "blockquote" + ) { return true; } // if its a text block, and not an empty block that is last on the page, @@ -101,9 +105,14 @@ } } - nextBlock.view.dispatch( - tr.setSelection(TextSelection.create(tr.doc, pos?.pos || 1)), - ); + if (block.type === "blockquote") { + let sel = NodeSelection.create(tr.doc, 0); + nextBlock.view.dispatch(tr.setSelection(sel)); + } else { + nextBlock.view.dispatch( + tr.setSelection(TextSelection.create(tr.doc, pos?.pos || 1)), + ); + } nextBlock.view.focus(); } diff --git a/src/utils/getBlocksAsHTML.tsx b/src/utils/getBlocksAsHTML.tsx --- a/src/utils/getBlocksAsHTML.tsx +++ b/src/utils/getBlocksAsHTML.tsx @@ -162,6 +162,31 @@ , ); } + if (b.type === "blockquote") { + let value = (await scanIndex(tx).eav(b.value, "block/text"))[0]; + if (!value) return "
"; + let doc = new Y.Doc(); + const update = base64.toByteArray(value.data.value); + Y.applyUpdate(doc, update); + let nodes = doc.getXmlElement("prosemirror").toArray(); + //Have to handle this specially because it's a multi-line block + return `
${nodes + .map((node) => { + if (node.constructor === Y.XmlElement) { + let children = node.toArray(); + if (children.length === 0) return "

"; + return renderToStaticMarkup( + , + ); + } + }) + .join("\n")}
`; + } let value = (await scanIndex(tx).eav(b.value, "block/text"))[0]; if (!value) return ignoreWrapper ? "" : `<${wrapper || "p"}>`; diff --git a/supabase/migrations/20250809164020_add_comments_table.sql b/supabase/migrations/20250809164020_add_comments_table.sql new file mode 100644 --- /dev/null +++ b/supabase/migrations/20250809164020_add_comments_table.sql @@ -0,0 +1,68 @@ +create table "public"."comments_on_documents" ( + "uri" text not null, + "record" jsonb not null, + "document" text, + "indexed_at" timestamp with time zone not null default now(), + "profile" text +); + + +alter table "public"."comments_on_documents" enable row level security; + +CREATE UNIQUE INDEX comments_on_documents_pkey ON public.comments_on_documents USING btree (uri); + +alter table "public"."comments_on_documents" add constraint "comments_on_documents_pkey" PRIMARY KEY using index "comments_on_documents_pkey"; + +alter table "public"."comments_on_documents" add constraint "comments_on_documents_document_fkey" FOREIGN KEY (document) REFERENCES documents(uri) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."comments_on_documents" validate constraint "comments_on_documents_document_fkey"; + +alter table "public"."comments_on_documents" add constraint "comments_on_documents_profile_fkey" FOREIGN KEY (profile) REFERENCES bsky_profiles(did) ON UPDATE CASCADE ON DELETE SET NULL not valid; + +alter table "public"."comments_on_documents" validate constraint "comments_on_documents_profile_fkey"; + +alter table "public"."publications" add constraint "publications_identity_did_fkey" FOREIGN KEY (identity_did) REFERENCES identities(atp_did) ON DELETE CASCADE not valid; + +alter table "public"."publications" validate constraint "publications_identity_did_fkey"; + +grant delete on table "public"."comments_on_documents" to "anon"; + +grant insert on table "public"."comments_on_documents" to "anon"; + +grant references on table "public"."comments_on_documents" to "anon"; + +grant select on table "public"."comments_on_documents" to "anon"; + +grant trigger on table "public"."comments_on_documents" to "anon"; + +grant truncate on table "public"."comments_on_documents" to "anon"; + +grant update on table "public"."comments_on_documents" to "anon"; + +grant delete on table "public"."comments_on_documents" to "authenticated"; + +grant insert on table "public"."comments_on_documents" to "authenticated"; + +grant references on table "public"."comments_on_documents" to "authenticated"; + +grant select on table "public"."comments_on_documents" to "authenticated"; + +grant trigger on table "public"."comments_on_documents" to "authenticated"; + +grant truncate on table "public"."comments_on_documents" to "authenticated"; + +grant update on table "public"."comments_on_documents" to "authenticated"; + +grant delete on table "public"."comments_on_documents" to "service_role"; + +grant insert on table "public"."comments_on_documents" to "service_role"; + +grant references on table "public"."comments_on_documents" to "service_role"; + +grant select on table "public"."comments_on_documents" to "service_role"; + +grant trigger on table "public"."comments_on_documents" to "service_role"; + +grant truncate on table "public"."comments_on_documents" to "service_role"; + +grant update on table "public"."comments_on_documents" to "service_role"; diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx --- a/components/Blocks/TextBlock/index.tsx +++ b/components/Blocks/TextBlock/index.tsx @@ -17,7 +17,7 @@ import { BlockProps } from "../Block"; import { focusBlock } from "src/utils/focusBlock"; import { TextBlockKeymap } from "./keymap"; -import { schema } from "./schema"; +import { multiBlockSchema, schema } from "./schema"; import { useUIState } from "src/useUIState"; import { addBlueskyPostBlock, addLinkBlock } from "src/utils/addLinkBlock"; import { BlockCommandBar } from "components/Blocks/BlockCommandBar"; @@ -44,7 +44,10 @@ } as { [level: number]: string }; export function TextBlock( - props: BlockProps & { className?: string; preview?: boolean }, + props: BlockProps & { + className?: string; + preview?: boolean; + }, ) { let isLocked = useEntity(props.entityID, "block/is-locked"); let initialized = useInitialPageLoad(); @@ -213,9 +216,14 @@ let handlePaste = useHandlePaste(props.entityID, propsRef); useLayoutEffect(() => { if (!mountRef.current) return; - let km = TextBlockKeymap(propsRef, repRef, rep.undoManager); + let km = TextBlockKeymap( + propsRef, + repRef, + rep.undoManager, + props.type === "blockquote", + ); let editor = EditorState.create({ - schema, + schema: props.type === "blockquote" ? multiBlockSchema : schema, plugins: [ ySyncPlugin(value), keymap(km), @@ -332,12 +340,15 @@ }, })); }; - }, [props.entityID, props.parent, value, handlePaste, rep]); + }, [props.entityID, props.parent, value, handlePaste, rep, props.type]); return ( <>
]{1})\s$/, (state, match) => {
+        let tr = state.tr;
+        tr.delete(0, 2);
+        repRef.current?.mutate.assertFact({
+          entity: propsRef.current.entityID,
+          attribute: "block/type",
+          data: { type: "block-type-union", value: "blockquote" },
+        });
+        return tr;
+      }),
+
       //Header
       new InputRule(/^([#]{1,3})\s$/, (state, match) => {
         let tr = state.tr;
diff --git a/components/Blocks/TextBlock/keymap.ts b/components/Blocks/TextBlock/keymap.ts
--- a/components/Blocks/TextBlock/keymap.ts
+++ b/components/Blocks/TextBlock/keymap.ts
@@ -2,7 +2,7 @@
 import { focusBlock } from "src/utils/focusBlock";
 import { EditorView } from "prosemirror-view";
 import { generateKeyBetween } from "fractional-indexing";
-import { setBlockType, toggleMark } from "prosemirror-commands";
+import { baseKeymap, setBlockType, toggleMark } from "prosemirror-commands";
 import { keymap } from "prosemirror-keymap";
 import {
   Command,
@@ -30,6 +30,7 @@
   propsRef: PropsRef,
   repRef: RefObject | null>,
   um: UndoManager,
+  multiLine?: boolean,
 ) =>
   ({
     "Meta-b": toggleMark(schema.marks.strong),
@@ -132,11 +133,16 @@
       ),
     "Shift-Backspace": backspace(propsRef, repRef),
     Enter: (state, dispatch, view) => {
+      if (multiLine && state.doc.content.size - state.selection.anchor > 1)
+        return false;
       return um.withUndoGroup(() =>
         enter(propsRef, repRef)(state, dispatch, view),
       );
     },
     "Shift-Enter": (state, dispatch, view) => {
+      if (multiLine) {
+        return baseKeymap.Enter(state, dispatch, view);
+      }
       return um.withUndoGroup(() =>
         enter(propsRef, repRef)(state, dispatch, view),
       );
diff --git a/components/Blocks/TextBlock/useHandlePaste.ts b/components/Blocks/TextBlock/useHandlePaste.ts
--- a/components/Blocks/TextBlock/useHandlePaste.ts
+++ b/components/Blocks/TextBlock/useHandlePaste.ts
@@ -3,7 +3,7 @@
 import { EditorView } from "prosemirror-view";
 import { setEditorState, useEditorStates } from "src/state/useEditorState";
 import { MarkType, DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
-import { schema } from "./schema";
+import { multiBlockSchema, schema } from "./schema";
 import { generateKeyBetween } from "fractional-indexing";
 import { addImage } from "src/utils/addImage";
 import { BlockProps } from "../Block";
@@ -19,6 +19,7 @@
 import { UndoManager } from "src/undoManager";
 
 const parser = ProsemirrorDOMParser.fromSchema(schema);
+const multilineParser = ProsemirrorDOMParser.fromSchema(multiBlockSchema);
 export const useHandlePaste = (
   entityID: string,
   propsRef: MutableRefObject,
@@ -180,7 +181,6 @@
     getPosition: () => string;
   },
 ) => {
-  let content = parser.parse(child);
   let type: Fact<"block/type">["data"]["value"] | null;
   let headingLevel: number | null = null;
   let hasChildren = false;
@@ -203,6 +203,10 @@
     }
   }
   switch (child.tagName) {
+    case "BLOCKQUOTE": {
+      type = "blockquote";
+      break;
+    }
     case "LI":
     case "SPAN": {
       type = "text";
@@ -250,6 +254,8 @@
     default:
       type = null;
   }
+  let content =
+    type === "blockquote" ? multilineParser.parse(child) : parser.parse(child);
   if (!type) return;
 
   let entityID: string;
@@ -492,7 +498,9 @@
         block.editor.selection.to !== undefined
       )
         tr.delete(block.editor.selection.from, block.editor.selection.to);
-      tr.insert(block.editor.selection.from || 1, content.content);
+      if (type === "blockquote") {
+        tr.replaceWith(0, tr.doc.content.size, content.content);
+      } else tr.insert(block.editor.selection.from || 1, content.content);
       let newState = block.editor.apply(tr);
       setEditorState(entityID, {
         editor: newState,
@@ -559,6 +567,7 @@
       // Collect outer HTML for paragraph-like elements
       if (
         [
+          "BLOCKQUOTE",
           "P",
           "PRE",
           "H1",
diff --git a/lexicons/pub/leaflet/blocks/blockquote.json b/lexicons/pub/leaflet/blocks/blockquote.json
new file mode 100644
--- /dev/null
+++ b/lexicons/pub/leaflet/blocks/blockquote.json
@@ -0,0 +1,24 @@
+{
+  "lexicon": 1,
+  "id": "pub.leaflet.blocks.blockquote",
+  "defs": {
+    "main": {
+      "type": "object",
+      "required": [
+        "plaintext"
+      ],
+      "properties": {
+        "plaintext": {
+          "type": "string"
+        },
+        "facets": {
+          "type": "array",
+          "items": {
+            "type": "ref",
+            "ref": "pub.leaflet.richtext.facet"
+          }
+        }
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/lexicons/pub/leaflet/pages/linearDocument.json b/lexicons/pub/leaflet/pages/linearDocument.json
--- a/lexicons/pub/leaflet/pages/linearDocument.json
+++ b/lexicons/pub/leaflet/pages/linearDocument.json
@@ -24,6 +24,7 @@
           "type": "union",
           "refs": [
             "pub.leaflet.blocks.text",
+            "pub.leaflet.blocks.blockquote",
             "pub.leaflet.blocks.header",
             "pub.leaflet.blocks.image",
             "pub.leaflet.blocks.unorderedList",
diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx
--- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx
@@ -9,6 +9,7 @@
   PubLeafletDocument,
   PubLeafletPagesLinearDocument,
   PubLeafletBlocksHorizontalRule,
+  PubLeafletBlocksBlockquote,
 } from "lexicons/api";
 import { blobRefToSrc } from "src/utils/blobRefToSrc";
 import { TextBlock } from "./TextBlock";
@@ -194,6 +195,21 @@
             
)} + ); + } + case PubLeafletBlocksBlockquote.isMain(b.block): { + return ( +
+ +
); } case PubLeafletBlocksText.isMain(b.block): diff --git a/lexicons/api/types/pub/leaflet/blocks/blockquote.ts b/lexicons/api/types/pub/leaflet/blocks/blockquote.ts new file mode 100644 --- /dev/null +++ b/lexicons/api/types/pub/leaflet/blocks/blockquote.ts @@ -0,0 +1,28 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../lexicons' +import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util' +import type * as PubLeafletRichtextFacet from '../richtext/facet' + +const is$typed = _is$typed, + validate = _validate +const id = 'pub.leaflet.blocks.blockquote' + +export interface Main { + $type?: 'pub.leaflet.blocks.blockquote' + plaintext: string + facets?: PubLeafletRichtextFacet.Main[] +} + +const hashMain = 'main' + +export function isMain(v: V) { + return is$typed(v, id, hashMain) +} + +export function validateMain(v: V) { + return validate
(v, id, hashMain) +} diff --git a/lexicons/api/types/pub/leaflet/pages/linearDocument.ts b/lexicons/api/types/pub/leaflet/pages/linearDocument.ts --- a/lexicons/api/types/pub/leaflet/pages/linearDocument.ts +++ b/lexicons/api/types/pub/leaflet/pages/linearDocument.ts @@ -6,6 +6,7 @@ import { validate as _validate } from '../../../../lexicons' import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util' import type * as PubLeafletBlocksText from '../blocks/text' +import type * as PubLeafletBlocksBlockquote from '../blocks/blockquote' import type * as PubLeafletBlocksHeader from '../blocks/header' import type * as PubLeafletBlocksImage from '../blocks/image' import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList' @@ -37,6 +38,7 @@ $type?: 'pub.leaflet.pages.linearDocument#block' block: | $Typed + | $Typed | $Typed | $Typed | $Typed -- tangled.sh