a tool for shared writing and social publishing

Feature/comment quotes (#199)

* add comment quote button and editor

* actually publish and render quotes in comments

* added some styling

---------

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

authored by awarm.space celine and committed by GitHub 730b6b1e 61401f5e

Changed files
+406 -144
app
components
lexicons
api
types
pub
pub
src
+67 -4
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 25 25 import { multi } from "linkifyjs"; 26 26 import { Json } from "supabase/database.types"; 27 27 import { isIOS } from "src/utils/isDevice"; 28 + import { 29 + decodeQuotePosition, 30 + QUOTE_PARAM, 31 + QuotePosition, 32 + } from "../../quotePosition"; 33 + import { QuoteContent } from "../Quotes"; 34 + import { create } from "zustand"; 35 + import { CloseTiny } from "components/Icons/CloseTiny"; 36 + import { CloseFillTiny } from "components/Icons/CloseFillTiny"; 28 37 29 38 export function CommentBox(props: { 30 39 doc_uri: string; ··· 32 41 onSubmit?: () => void; 33 42 }) { 34 43 let mountRef = useRef<HTMLPreElement | null>(null); 44 + let quote = useInteractionState((s) => s.commentBox.quote); 35 45 let [editorState, setEditorState] = useState(() => 36 46 EditorState.create({ 37 47 schema: multiBlockSchema, ··· 61 71 { mount: mountRef.current }, 62 72 { 63 73 state: editorState, 74 + handlePaste: (view, e) => { 75 + let text = 76 + e.clipboardData?.getData("text") || 77 + e.clipboardData?.getData("text/html"); 78 + let html = e.clipboardData?.getData("text/html"); 79 + if (!text && html) { 80 + let xml = new DOMParser().parseFromString(html, "text/html"); 81 + text = xml.textContent || ""; 82 + } 83 + console.log("URL: " + window.location.toString()); 84 + console.log("TEXT: " + text, text?.includes(QUOTE_PARAM)); 85 + if ( 86 + text?.includes(QUOTE_PARAM) && 87 + text.includes(window.location.toString()) 88 + ) { 89 + const url = new URL(text); 90 + const quoteParam = url.pathname.split("/l-quote/")[1]; 91 + if (!quoteParam) return; 92 + const quotePosition = decodeQuotePosition(quoteParam); 93 + if (!quotePosition) return; 94 + useInteractionState.setState({ 95 + commentBox: { quote: quotePosition }, 96 + }); 97 + return true; 98 + } 99 + }, 64 100 handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { 65 101 if (!direct) return; 66 102 if (node.nodeSize - 2 <= _pos) return; ··· 90 126 }, []); 91 127 let [loading, setLoading] = useState(false); 92 128 return ( 93 - <div className=" flex flex-col gap-1"> 129 + <div className=" flex flex-col"> 130 + {quote && ( 131 + <div className="relative mt-2 mb-2"> 132 + <QuoteContent position={quote} did="" index={-1} /> 133 + <button 134 + className="text-border absolute -top-3 right-1 bg-bg-page p-1 rounded-full" 135 + onClick={() => 136 + useInteractionState.setState({ commentBox: { quote: null } }) 137 + } 138 + > 139 + <CloseFillTiny /> 140 + </button> 141 + </div> 142 + )} 94 143 <div className="w-full relative group"> 95 144 <pre 96 145 ref={mountRef} 97 - className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit`} 146 + className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit !px-2 !py-[6px]`} 98 147 /> 99 148 <IOSBS view={view} /> 100 149 </div> 101 - <div className="flex justify-between"> 150 + <div className="flex justify-between pt-1"> 102 151 <div className="flex gap-1"> 103 152 <TextDecorationButton 104 153 mark={multiBlockSchema.marks.strong} ··· 126 175 let [plaintext, facets] = docToFacetedText(editorState.doc); 127 176 let comment = await publishComment({ 128 177 document: props.doc_uri, 129 - comment: { plaintext, facets, replyTo: props.replyTo }, 178 + comment: { 179 + plaintext, 180 + facets, 181 + replyTo: props.replyTo, 182 + attachment: quote 183 + ? { 184 + $type: "pub.leaflet.comment#linearDocumentQuote", 185 + document: props.doc_uri, 186 + quote, 187 + } 188 + : undefined, 189 + }, 130 190 }); 131 191 132 192 setLoading(false); 133 193 props.onSubmit?.(); 134 194 useInteractionState.setState((s) => ({ 195 + commentBox: { 196 + quote: null, 197 + }, 135 198 localComments: [ 136 199 ...s.localComments, 137 200 {
+2
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
··· 15 15 plaintext: string; 16 16 facets: PubLeafletRichtextFacet.Main[]; 17 17 replyTo?: string; 18 + attachment: PubLeafletComment.Record["attachment"]; 18 19 }; 19 20 }) { 20 21 const oauthClient = await createOauthClient(); ··· 31 32 plaintext: args.comment.plaintext, 32 33 facets: args.comment.facets, 33 34 reply: args.comment.replyTo ? { parent: args.comment.replyTo } : undefined, 35 + attachment: args.comment.attachment, 34 36 }; 35 37 let rkey = TID.nextStr(); 36 38 let uri = AtUri.make(credentialSession.did!, "pub.leaflet.comment", rkey);
+14 -3
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 16 16 import { timeAgo } from "app/discover/PubListing"; 17 17 import { BlueskyLogin } from "app/login/LoginForm"; 18 18 import { usePathname } from "next/navigation"; 19 + import { QuoteContent } from "../Quotes"; 19 20 20 21 export type Comment = { 21 22 record: Json; ··· 57 58 </div> 58 59 )} 59 60 <hr className="border-border-light" /> 60 - <div className="flex flex-col gap-6"> 61 + <div className="flex flex-col gap-6 py-2"> 61 62 {comments 62 63 .sort((a, b) => { 63 64 let aRecord = a.record as PubLeafletComment.Record; ··· 99 100 }) => { 100 101 return ( 101 102 <div className="comment"> 102 - <div className="flex gap-2 "> 103 + <div className="flex gap-2"> 103 104 {props.profile && ( 104 105 <ProfilePopover profile={props.profile} comment={props.comment.uri} /> 105 106 )} 106 107 <DatePopover date={props.record.createdAt} /> 107 108 </div> 109 + {props.record.attachment && 110 + PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && ( 111 + <div className="mt-1 mb-2"> 112 + <QuoteContent 113 + index={-1} 114 + position={props.record.attachment.quote} 115 + did={new AtUri(props.record.attachment.document).host} 116 + /> 117 + </div> 118 + )} 108 119 <pre 109 120 key={props.comment.uri} 110 121 style={{ wordBreak: "break-word" }} 111 - className="whitespace-pre-wrap text-secondary pb-[4px]" 122 + className="whitespace-pre-wrap text-secondary pb-[4px] " 112 123 > 113 124 <BaseTextBlock 114 125 index={[]}
+2
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 5 5 import type { Json } from "supabase/database.types"; 6 6 import { create } from "zustand"; 7 7 import type { Comment } from "./Comments"; 8 + import { QuotePosition } from "../quotePosition"; 8 9 9 10 export let useInteractionState = create(() => ({ 10 11 drawerOpen: undefined as boolean | undefined, 11 12 drawer: undefined as undefined | "comments" | "quotes", 12 13 localComments: [] as Comment[], 14 + commentBox: { quote: null as QuotePosition | null }, 13 15 })); 14 16 export function openInteractionDrawer(drawer: "comments" | "quotes") { 15 17 flushSync(() => {
+77 -68
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 15 15 PubLeafletPagesLinearDocument, 16 16 PubLeafletBlocksCode, 17 17 } from "lexicons/api"; 18 - import { 19 - decodeQuotePosition, 20 - QuotePosition, 21 - useActiveHighlightState, 22 - } from "../useHighlight"; 18 + import { decodeQuotePosition, QuotePosition } from "../quotePosition"; 19 + import { useActiveHighlightState } from "../useHighlight"; 23 20 import { PostContent } from "../PostContent"; 24 21 import { ProfileViewBasic } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 25 22 ··· 27 24 quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; 28 25 did: string; 29 26 }) => { 30 - let isMobile = useIsMobile(); 31 27 let data = useContext(PostPageContext); 32 28 33 29 return ( ··· 50 46 <div className="quotes flex flex-col gap-8"> 51 47 {props.quotes.map((q, index) => { 52 48 let pv = q.bsky_posts?.post_view as unknown as PostView; 53 - let record = data?.data as PubLeafletDocument.Record; 54 49 const url = new URL(q.link); 55 50 const quoteParam = url.pathname.split("/l-quote/")[1]; 56 51 if (!quoteParam) return null; 57 52 const quotePosition = decodeQuotePosition(quoteParam); 58 53 if (!quotePosition) return null; 59 - 60 - let page = record.pages[0] as PubLeafletPagesLinearDocument.Main; 61 - // Extract blocks within the quote range 62 - const content = extractQuotedBlocks( 63 - page.blocks || [], 64 - quotePosition, 65 - [], 66 - ); 67 54 return ( 68 - <div 69 - className="quoteSection flex flex-col gap-2" 70 - key={index} 71 - onMouseLeave={() => { 72 - useActiveHighlightState.setState({ activeHighlight: null }); 73 - }} 74 - onMouseEnter={() => { 75 - useActiveHighlightState.setState({ activeHighlight: index }); 76 - }} 77 - > 78 - <div 79 - className="quoteSectionQuote text-secondary text-sm text-left pb-1 hover:cursor-pointer" 80 - onClick={(e) => { 81 - let scrollMargin = isMobile 82 - ? 16 83 - : e.currentTarget.getBoundingClientRect().top; 84 - let scrollContainer = 85 - window.document.getElementById("post-page"); 86 - let el = window.document.getElementById( 87 - quotePosition.start.block.join("."), 88 - ); 89 - if (!el || !scrollContainer) return; 90 - let blockRect = el.getBoundingClientRect(); 91 - let quoteScrollTop = 92 - (scrollContainer && 93 - blockRect.top + scrollContainer.scrollTop) || 94 - 0; 55 + <div key={index} className="flex flex-col "> 56 + <QuoteContent 57 + index={index} 58 + did={props.did} 59 + position={quotePosition} 60 + /> 95 61 96 - if (blockRect.left < 0) 97 - scrollContainer.scrollIntoView({ behavior: "smooth" }); 98 - scrollContainer?.scrollTo({ 99 - top: quoteScrollTop - scrollMargin, 100 - behavior: "smooth", 101 - }); 102 - }} 103 - > 104 - <div className="italic"> 105 - <PostContent 106 - bskyPostData={[]} 107 - blocks={content || []} 108 - did={props.did} 109 - preview 110 - /> 111 - </div> 112 - <BskyPost 113 - rkey={new AtUri(pv.uri).rkey} 114 - content={pv.record.text as string} 115 - user={pv.author.displayName || pv.author.handle} 116 - profile={pv.author} 117 - handle={pv.author.handle} 118 - /> 119 - </div> 62 + <div className="h-5 w-1 ml-5 border-l border-border-light" /> 63 + <BskyPost 64 + rkey={new AtUri(pv.uri).rkey} 65 + content={pv.record.text as string} 66 + user={pv.author.displayName || pv.author.handle} 67 + profile={pv.author} 68 + handle={pv.author.handle} 69 + /> 120 70 </div> 121 71 ); 122 72 })} ··· 126 76 ); 127 77 }; 128 78 79 + export const QuoteContent = (props: { 80 + position: QuotePosition; 81 + index: number; 82 + did: string; 83 + }) => { 84 + let isMobile = useIsMobile(); 85 + const data = useContext(PostPageContext); 86 + 87 + let record = data?.data as PubLeafletDocument.Record; 88 + let page = record.pages[0] as PubLeafletPagesLinearDocument.Main; 89 + // Extract blocks within the quote range 90 + const content = extractQuotedBlocks(page.blocks || [], props.position, []); 91 + return ( 92 + <div 93 + className="quoteSection" 94 + onMouseLeave={() => { 95 + useActiveHighlightState.setState({ activeHighlight: null }); 96 + }} 97 + onMouseEnter={() => { 98 + useActiveHighlightState.setState({ activeHighlight: props.index }); 99 + }} 100 + > 101 + <div 102 + className="quoteSectionQuote text-secondary text-sm text-left hover:cursor-pointer" 103 + onClick={(e) => { 104 + let scrollMargin = isMobile 105 + ? 16 106 + : e.currentTarget.getBoundingClientRect().top; 107 + let scrollContainer = window.document.getElementById("post-page"); 108 + let el = window.document.getElementById( 109 + props.position.start.block.join("."), 110 + ); 111 + if (!el || !scrollContainer) return; 112 + let blockRect = el.getBoundingClientRect(); 113 + let quoteScrollTop = 114 + (scrollContainer && blockRect.top + scrollContainer.scrollTop) || 0; 115 + 116 + if (blockRect.left < 0) 117 + scrollContainer.scrollIntoView({ behavior: "smooth" }); 118 + scrollContainer?.scrollTo({ 119 + top: quoteScrollTop - scrollMargin, 120 + behavior: "smooth", 121 + }); 122 + }} 123 + > 124 + <div className="italic border border-border-light rounded-md px-2 pt-1"> 125 + <PostContent 126 + bskyPostData={[]} 127 + blocks={content} 128 + did={props.did} 129 + preview 130 + className="!py-0" 131 + /> 132 + </div> 133 + </div> 134 + </div> 135 + ); 136 + }; 137 + 129 138 const BskyPost = (props: { 130 139 rkey: string; 131 140 content: string; ··· 137 146 <a 138 147 target="_blank" 139 148 href={`https://bsky.app/profile/${props.handle}/post/${props.rkey}`} 140 - className="quoteSectionBskyItem opaque-container py-2 px-2 text-sm flex gap-[6px] hover:no-underline font-normal" 149 + className="quoteSectionBskyItem px-2 flex gap-[6px] hover:no-underline font-normal" 141 150 > 142 151 {props.profile.avatar && ( 143 152 <img ··· 151 160 <div className="font-bold">{props.user}</div> 152 161 <div className="text-tertiary">@{props.handle}</div> 153 162 </div> 154 - <div className="text-secondary">{props.content}</div> 163 + <div className="text-primary">{props.content}</div> 155 164 </div> 156 165 </a> 157 166 );
+8 -3
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 29 29 blocks, 30 30 did, 31 31 preview, 32 + className, 32 33 prerenderedCodeBlocks, 33 34 bskyPostData, 34 35 }: { 35 36 blocks: PubLeafletPagesLinearDocument.Block[]; 36 37 did: string; 37 38 preview?: boolean; 39 + className?: string; 38 40 prerenderedCodeBlocks?: Map<string, string>; 39 41 bskyPostData: AppBskyFeedDefs.PostView[]; 40 42 }) { 41 43 return ( 42 - <div id="post-content" className="postContent flex flex-col"> 44 + <div 45 + id="post-content" 46 + className={`postContent flex flex-col pb-1 sm:pb-2 pt-1 sm:pt-2 ${className}`} 47 + > 43 48 {blocks.map((b, index) => { 44 49 return ( 45 50 <Block ··· 99 104 // non text blocks, they need this padding, pt-3 sm:pt-4, which is applied in each case 100 105 let className = ` 101 106 postBlockWrapper 102 - pt-1 103 107 min-h-7 104 - ${isList ? "isListItem pb-0 " : "pb-2 last:pb-3 last:sm:pb-4 first:pt-2 sm:first:pt-3"} 108 + pt-1 pb-2 109 + ${isList && "isListItem !pb-0 "} 105 110 ${alignment} 106 111 `; 107 112
+32 -10
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
··· 4 4 import { Separator } from "components/Layout"; 5 5 import { useSmoker } from "components/Toast"; 6 6 import { useEffect, useMemo, useState } from "react"; 7 + import { 8 + encodeQuotePosition, 9 + decodeQuotePosition, 10 + QuotePosition, 11 + } from "./quotePosition"; 12 + import { useIdentityData } from "components/IdentityProvider"; 13 + import { CommentTiny } from "components/Icons/CommentTiny"; 7 14 import { useInteractionState } from "./Interactions/Interactions"; 8 - import { encodeQuotePosition } from "./useHighlight"; 9 - import { useParams } from "next/navigation"; 10 - import { decodeQuotePosition, QuotePosition } from "./quotePosition"; 11 15 12 16 export function QuoteHandler() { 13 17 let [position, setPosition] = useState<{ ··· 108 112 return ( 109 113 <div 110 114 id="quote-trigger" 111 - className={`accent-container border border-border-light text-accent-contrast px-2 flex gap-1 text-sm justify-center text-center items-center`} 115 + className={`accent-container border border-border-light text-accent-contrast px-1 flex gap-1 text-sm justify-center text-center items-center`} 112 116 style={{ 113 117 position: "absolute", 114 118 top: position.top, ··· 123 127 124 128 export const QuoteOptionButtons = (props: { position: string }) => { 125 129 let smoker = useSmoker(); 126 - let url = useMemo(() => { 130 + let { identity } = useIdentityData(); 131 + let [url, position] = useMemo(() => { 127 132 let currentUrl = new URL(window.location.href); 128 133 let pos = decodeQuotePosition(props.position); 129 134 if (currentUrl.pathname.includes("/l-quote/")) { ··· 132 137 currentUrl.pathname = currentUrl.pathname + `/l-quote/${props.position}`; 133 138 134 139 currentUrl.hash = `#${pos?.start.block.join(".")}_${pos?.start.offset}`; 135 - return currentUrl.toString(); 140 + return [currentUrl.toString(), pos]; 136 141 }, [props.position]); 137 142 138 143 return ( 139 144 <> 140 - <div className="">Share Quote via</div> 145 + <div className="">Share via</div> 141 146 142 147 <a 143 - className="flex gap-1 items-center hover:font-bold p-1" 148 + className="flex gap-1 items-center hover:font-bold px-1 hover:!no-underline" 144 149 role="link" 145 150 href={`https://bsky.app/intent/compose?text=${encodeURIComponent(url)}`} 146 151 target="_blank" ··· 148 153 <BlueskyLinkTiny className="shrink-0" /> 149 154 Bluesky 150 155 </a> 151 - <Separator classname="h-3" /> 156 + <Separator classname="h-4" /> 152 157 <button 153 158 id="copy-quote-link" 154 - className="flex gap-1 items-center hover:font-bold p-1" 159 + className="flex gap-1 items-center hover:font-bold px-1" 155 160 onClick={() => { 156 161 let rect = document 157 162 .getElementById("copy-quote-link") ··· 171 176 <CopyTiny className="shrink-0" /> 172 177 Link 173 178 </button> 179 + <Separator classname="h-4" /> 180 + 181 + {identity?.atp_did && ( 182 + <button 183 + className="flex gap-1 items-center hover:font-bold px-1" 184 + onClick={() => { 185 + if (!position) return; 186 + useInteractionState.setState({ 187 + drawer: "comments", 188 + drawerOpen: true, 189 + commentBox: { quote: position }, 190 + }); 191 + }} 192 + > 193 + <CommentTiny /> Comment 194 + </button> 195 + )} 174 196 </> 175 197 ); 176 198 };
+1 -1
app/lish/[did]/[publication]/[rkey]/quotePosition.ts
··· 9 9 }; 10 10 } 11 11 12 - export const QUOTE_PARAM = "l_quote"; 12 + export const QUOTE_PARAM = "/l-quote/"; 13 13 14 14 /** 15 15 * Encodes quote position into a URL-friendly string
+1 -55
app/lish/[did]/[publication]/[rkey]/useHighlight.tsx
··· 5 5 import { useContext } from "react"; 6 6 import { PostPageContext } from "./PostPageContext"; 7 7 import { create } from "zustand"; 8 - 9 - export interface QuotePosition { 10 - start: { 11 - block: number[]; 12 - offset: number; 13 - }; 14 - end: { 15 - block: number[]; 16 - offset: number; 17 - }; 18 - } 8 + import { decodeQuotePosition } from "./quotePosition"; 19 9 20 10 export const useActiveHighlightState = create(() => ({ 21 11 activeHighlight: null as null | number, ··· 77 67 }) 78 68 .filter((highlight) => highlight !== null); 79 69 }; 80 - 81 - /** 82 - * Encodes quote position into a URL-friendly string 83 - * Format: startBlock_startOffset-endBlock_endOffset 84 - * Block paths are joined with dots: 1.2.0_45-1.2.3_67 85 - * Simple blocks: 0:12-2:45 86 - */ 87 - export function encodeQuotePosition(position: QuotePosition): string { 88 - const { start, end } = position; 89 - return `${start.block.join(".")}_${start.offset}-${end.block.join(".")}_${end.offset}`; 90 - } 91 - 92 - /** 93 - * Decodes quote position from URL parameter 94 - * Returns null if the format is invalid 95 - */ 96 - export function decodeQuotePosition(encoded: string): QuotePosition | null { 97 - try { 98 - // Match format: blockPath:number-blockPath:number 99 - // Block paths can be: 5, 1.2, 0.1.3, etc. 100 - const match = encoded.match(/^([\d.]+)_(\d+)-([\d.]+)_(\d+)$/); 101 - 102 - if (!match) { 103 - return null; 104 - } 105 - 106 - const [, startBlockPath, startOffset, endBlockPath, endOffset] = match; 107 - 108 - const position: QuotePosition = { 109 - start: { 110 - block: startBlockPath.split(".").map((i) => parseInt(i)), 111 - offset: parseInt(startOffset, 10), 112 - }, 113 - end: { 114 - block: endBlockPath.split(".").map((i) => parseInt(i)), 115 - offset: parseInt(endOffset, 10), 116 - }, 117 - }; 118 - 119 - return position; 120 - } catch (error) { 121 - return null; 122 - } 123 - }
+21
components/Icons/CloseFillTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const CloseFillTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + fillRule="evenodd" 15 + clipRule="evenodd" 16 + d="M8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16ZM11.954 4.04603C12.282 4.37407 12.282 4.90593 11.954 5.23397L9.18794 8L11.954 10.766C12.282 11.0941 12.282 11.6259 11.954 11.954C11.6259 12.282 11.0941 12.282 10.766 11.954L8 9.18794L5.23397 11.954C4.90593 12.282 4.37407 12.282 4.04603 11.954C3.71799 11.6259 3.71799 11.0941 4.04603 10.766L6.81206 8L4.04603 5.23397C3.71799 4.90593 3.71799 4.37407 4.04603 4.04603C4.37407 3.71799 4.90593 3.71799 5.23397 4.04603L8 6.81206L10.766 4.04603C11.0941 3.71799 11.6259 3.71799 11.954 4.04603Z" 17 + fill="currentColor" 18 + /> 19 + </svg> 20 + ); 21 + };
+47
lexicons/api/lexicons.ts
··· 46 46 ref: 'lex:pub.leaflet.richtext.facet', 47 47 }, 48 48 }, 49 + attachment: { 50 + type: 'union', 51 + refs: ['lex:pub.leaflet.comment#linearDocumentQuote'], 52 + }, 53 + }, 54 + }, 55 + }, 56 + linearDocumentQuote: { 57 + type: 'object', 58 + required: ['document', 'quote'], 59 + properties: { 60 + document: { 61 + type: 'string', 62 + format: 'at-uri', 63 + }, 64 + quote: { 65 + type: 'ref', 66 + ref: 'lex:pub.leaflet.pages.linearDocument#quote', 49 67 }, 50 68 }, 51 69 }, ··· 551 569 }, 552 570 textAlignRight: { 553 571 type: 'token', 572 + }, 573 + quote: { 574 + type: 'object', 575 + required: ['start', 'end'], 576 + properties: { 577 + start: { 578 + type: 'ref', 579 + ref: 'lex:pub.leaflet.pages.linearDocument#position', 580 + }, 581 + end: { 582 + type: 'ref', 583 + ref: 'lex:pub.leaflet.pages.linearDocument#position', 584 + }, 585 + }, 586 + }, 587 + position: { 588 + type: 'object', 589 + required: ['block', 'offset'], 590 + properties: { 591 + block: { 592 + type: 'array', 593 + items: { 594 + type: 'integer', 595 + }, 596 + }, 597 + offset: { 598 + type: 'integer', 599 + }, 600 + }, 554 601 }, 555 602 }, 556 603 },
+18
lexicons/api/types/pub/leaflet/comment.ts
··· 6 6 import { validate as _validate } from '../../../lexicons' 7 7 import { $Typed, is$typed as _is$typed, OmitKey } from '../../../util' 8 8 import type * as PubLeafletRichtextFacet from './richtext/facet' 9 + import type * as PubLeafletPagesLinearDocument from './pages/linearDocument' 9 10 10 11 const is$typed = _is$typed, 11 12 validate = _validate ··· 18 19 reply?: ReplyRef 19 20 plaintext: string 20 21 facets?: PubLeafletRichtextFacet.Main[] 22 + attachment?: $Typed<LinearDocumentQuote> | { $type: string } 21 23 [k: string]: unknown 22 24 } 23 25 ··· 29 31 30 32 export function validateRecord<V>(v: V) { 31 33 return validate<Record & V>(v, id, hashRecord, true) 34 + } 35 + 36 + export interface LinearDocumentQuote { 37 + $type?: 'pub.leaflet.comment#linearDocumentQuote' 38 + document: string 39 + quote: PubLeafletPagesLinearDocument.Quote 40 + } 41 + 42 + const hashLinearDocumentQuote = 'linearDocumentQuote' 43 + 44 + export function isLinearDocumentQuote<V>(v: V) { 45 + return is$typed(v, id, hashLinearDocumentQuote) 46 + } 47 + 48 + export function validateLinearDocumentQuote<V>(v: V) { 49 + return validate<LinearDocumentQuote & V>(v, id, hashLinearDocumentQuote) 32 50 } 33 51 34 52 export interface ReplyRef {
+32
lexicons/api/types/pub/leaflet/pages/linearDocument.ts
··· 71 71 export const TEXTALIGNLEFT = `${id}#textAlignLeft` 72 72 export const TEXTALIGNCENTER = `${id}#textAlignCenter` 73 73 export const TEXTALIGNRIGHT = `${id}#textAlignRight` 74 + 75 + export interface Quote { 76 + $type?: 'pub.leaflet.pages.linearDocument#quote' 77 + start: Position 78 + end: Position 79 + } 80 + 81 + const hashQuote = 'quote' 82 + 83 + export function isQuote<V>(v: V) { 84 + return is$typed(v, id, hashQuote) 85 + } 86 + 87 + export function validateQuote<V>(v: V) { 88 + return validate<Quote & V>(v, id, hashQuote) 89 + } 90 + 91 + export interface Position { 92 + $type?: 'pub.leaflet.pages.linearDocument#position' 93 + block: number[] 94 + offset: number 95 + } 96 + 97 + const hashPosition = 'position' 98 + 99 + export function isPosition<V>(v: V) { 100 + return is$typed(v, id, hashPosition) 101 + } 102 + 103 + export function validatePosition<V>(v: V) { 104 + return validate<Position & V>(v, id, hashPosition) 105 + }
+23
lexicons/pub/leaflet/comment.json
··· 37 37 "type": "ref", 38 38 "ref": "pub.leaflet.richtext.facet" 39 39 } 40 + }, 41 + "attachment": { 42 + "type": "union", 43 + "refs": [ 44 + "#linearDocumentQuote" 45 + ] 40 46 } 47 + } 48 + } 49 + }, 50 + "linearDocumentQuote": { 51 + "type": "object", 52 + "required": [ 53 + "document", 54 + "quote" 55 + ], 56 + "properties": { 57 + "document": { 58 + "type": "string", 59 + "format": "at-uri" 60 + }, 61 + "quote": { 62 + "type": "ref", 63 + "ref": "pub.leaflet.pages.linearDocument#quote" 41 64 } 42 65 } 43 66 },
+35
lexicons/pub/leaflet/pages/linearDocument.json
··· 54 54 }, 55 55 "textAlignRight": { 56 56 "type": "token" 57 + }, 58 + "quote": { 59 + "type": "object", 60 + "required": [ 61 + "start", 62 + "end" 63 + ], 64 + "properties": { 65 + "start": { 66 + "type": "ref", 67 + "ref": "#position" 68 + }, 69 + "end": { 70 + "type": "ref", 71 + "ref": "#position" 72 + } 73 + } 74 + }, 75 + "position": { 76 + "type": "object", 77 + "required": [ 78 + "block", 79 + "offset" 80 + ], 81 + "properties": { 82 + "block": { 83 + "type": "array", 84 + "items": { 85 + "type": "integer" 86 + } 87 + }, 88 + "offset": { 89 + "type": "integer" 90 + } 91 + } 57 92 } 58 93 } 59 94 }
+9
lexicons/src/comment.ts
··· 23 23 type: "array", 24 24 items: { type: "ref", ref: PubLeafletRichTextFacet.id }, 25 25 }, 26 + attachment: { type: "union", refs: ["#linearDocumentQuote"] }, 26 27 }, 28 + }, 29 + }, 30 + linearDocumentQuote: { 31 + type: "object", 32 + required: ["document", "quote"], 33 + properties: { 34 + document: { type: "string", format: "at-uri" }, 35 + quote: { type: "ref", ref: "pub.leaflet.pages.linearDocument#quote" }, 27 36 }, 28 37 }, 29 38 replyRef: {
+16
lexicons/src/pages/LinearDocument.ts
··· 29 29 textAlignLeft: { type: "token" }, 30 30 textAlignCenter: { type: "token" }, 31 31 textAlignRight: { type: "token" }, 32 + quote: { 33 + type: "object", 34 + required: ["start", "end"], 35 + properties: { 36 + start: { type: "ref", ref: "#position" }, 37 + end: { type: "ref", ref: "#position" }, 38 + }, 39 + }, 40 + position: { 41 + type: "object", 42 + required: ["block", "offset"], 43 + properties: { 44 + block: { type: "array", items: { type: "integer" } }, 45 + offset: { type: "integer" }, 46 + }, 47 + }, 32 48 }, 33 49 };
+1
next-env.d.ts
··· 1 1 /// <reference types="next" /> 2 2 /// <reference types="next/image-types/global" /> 3 + /// <reference path="./.next/types/routes.d.ts" /> 3 4 4 5 // NOTE: This file should not be edited 5 6 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.