diff --git a/app/globals.css b/app/globals.css index d2872762..fb80c249 100644 --- 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/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index 632386bd..73febd83 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -9,6 +9,7 @@ import { PubLeafletDocument, PubLeafletPagesLinearDocument, PubLeafletBlocksHorizontalRule, + PubLeafletBlocksBlockquote, } from "lexicons/api"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { TextBlock } from "./TextBlock"; @@ -196,6 +197,21 @@ let Block = ({ ); } + case PubLeafletBlocksBlockquote.isMain(b.block): { + return ( +
+ +
+ ); + } case PubLeafletBlocksText.isMain(b.block): return (

diff --git a/components/Blocks/Block.tsx b/components/Blocks/Block.tsx index 91f4c1f1..8fa2a138 100644 --- a/components/Blocks/Block.tsx +++ b/components/Blocks/Block.tsx @@ -175,6 +175,7 @@ const BlockTypeComponents: { 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 index 303b130b..cbd2d940 100644 --- a/components/Blocks/BlockCommands.tsx +++ b/components/Blocks/BlockCommands.tsx @@ -31,6 +31,8 @@ import { BlockRSVPSmall } from "components/Icons/BlockRSVPSmall"; 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; @@ -158,6 +160,16 @@ export const blockCommands: Command[] = [ 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); + }, + }, { name: "Image", diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx index 029b8a47..f506ab67 100644 --- a/components/Blocks/TextBlock/index.tsx +++ b/components/Blocks/TextBlock/index.tsx @@ -17,7 +17,7 @@ import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 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 @@ const HeadingStyle = { } 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 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) { 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 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) { }, })); }; - }, [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
index f68124fd..8e123f83 100644
--- a/components/Blocks/TextBlock/keymap.ts
+++ b/components/Blocks/TextBlock/keymap.ts
@@ -2,7 +2,7 @@ import { BlockProps } from "../Block";
 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 @@ export const TextBlockKeymap = (
   propsRef: PropsRef,
   repRef: RefObject | null>,
   um: UndoManager,
+  multiLine?: boolean,
 ) =>
   ({
     "Meta-b": toggleMark(schema.marks.strong),
@@ -132,11 +133,16 @@ export const TextBlockKeymap = (
       ),
     "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
index e6cb142d..fcd63fa6 100644
--- a/components/Blocks/TextBlock/useHandlePaste.ts
+++ b/components/Blocks/TextBlock/useHandlePaste.ts
@@ -3,7 +3,7 @@ import { Fact, ReplicacheMutators, useReplicache } from "src/replicache";
 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 { addLinkBlock } from "src/utils/addLinkBlock";
 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 @@ const createBlockFromHTML = (
     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 @@ const createBlockFromHTML = (
     }
   }
   switch (child.tagName) {
+    case "BLOCKQUOTE": {
+      type = "blockquote";
+      break;
+    }
     case "LI":
     case "SPAN": {
       type = "text";
@@ -250,6 +254,8 @@ const createBlockFromHTML = (
     default:
       type = null;
   }
+  let content =
+    type === "blockquote" ? multilineParser.parse(child) : parser.parse(child);
   if (!type) return;
 
   let entityID: string;
@@ -492,7 +498,9 @@ const createBlockFromHTML = (
         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 @@ function flattenHTMLToTextBlocks(element: HTMLElement): HTMLElement[] {
       // Collect outer HTML for paragraph-like elements
       if (
         [
+          "BLOCKQUOTE",
           "P",
           "PRE",
           "H1",
diff --git a/components/Icons/QuoteSmall.tsx b/components/Icons/QuoteSmall.tsx
new file mode 100644
index 00000000..c341fe7e
--- /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
index bb38a256..d9047ebc 100644
--- a/components/Toolbar/index.tsx
+++ b/components/Toolbar/index.tsx
@@ -65,7 +65,11 @@ export const Toolbar = (props: { pageID: string; blockID: string }) => {
 
   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
index 8081a202..79f28f30 100644
--- a/lexicons/api/index.ts
+++ b/lexicons/api/index.ts
@@ -7,6 +7,7 @@ import { CID } from 'multiformats/cid'
 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 @@ import * as AppBskyActorProfile from './types/app/bsky/actor/profile'
 
 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
index f54ffa62..fc429bd1 100644
--- a/lexicons/api/lexicons.ts
+++ b/lexicons/api/lexicons.ts
@@ -161,6 +161,28 @@ export const schemaDict = {
       },
     },
   },
+  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 @@ export const schemaDict = {
             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 function validate(
 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/api/types/pub/leaflet/blocks/blockquote.ts b/lexicons/api/types/pub/leaflet/blocks/blockquote.ts
new file mode 100644
index 00000000..54365f6c
--- /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 index e29bab03..110a48a1 100644 --- a/lexicons/api/types/pub/leaflet/pages/linearDocument.ts +++ b/lexicons/api/types/pub/leaflet/pages/linearDocument.ts @@ -6,6 +6,7 @@ import { CID } from 'multiformats/cid' 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 @@ export interface Block { $type?: 'pub.leaflet.pages.linearDocument#block' block: | $Typed + | $Typed | $Typed | $Typed | $Typed diff --git a/lexicons/pub/leaflet/blocks/blockquote.json b/lexicons/pub/leaflet/blocks/blockquote.json new file mode 100644 index 00000000..bf4e9555 --- /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 index aea5936f..de763d86 100644 --- 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/lexicons/src/blocks.ts b/lexicons/src/blocks.ts index 850f477f..c499738b 100644 --- a/lexicons/src/blocks.ts +++ b/lexicons/src/blocks.ts @@ -19,6 +19,24 @@ export const PubLeafletBlocksText: LexiconDoc = { }, }; +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 PubLeafletBlocksUnorderedList: LexiconDoc = { }; export const BlockLexicons = [ PubLeafletBlocksText, + PubLeafletBlocksBlockQuote, PubLeafletBlocksHeader, PubLeafletBlocksImage, PubLeafletBlocksUnorderedList, diff --git a/src/replicache/attributes.ts b/src/replicache/attributes.ts index a574c7bc..df31d318 100644 --- a/src/replicache/attributes.ts +++ b/src/replicache/attributes.ts @@ -332,6 +332,7 @@ export type Data = { | "bluesky-post" | "math" | "code" + | "blockquote" | "horizontal-rule"; }; "canvas-pattern-union": { diff --git a/src/utils/focusBlock.ts b/src/utils/focusBlock.ts index ffd827ce..2dc4272a 100644 --- 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 @@ export function focusBlock( } // 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 @@ export function focusBlock( } } - 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 index c0970ce4..cb89a099 100644 --- a/src/utils/getBlocksAsHTML.tsx +++ b/src/utils/getBlocksAsHTML.tsx @@ -162,6 +162,31 @@ async function renderBlock(
, ); } + 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 index 00000000..bd32145d --- /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";