a tool for shared writing and social publishing

process text and facets together for bsky post editor

+71 -64
+66 -59
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 12 12 import * as Popover from "@radix-ui/react-popover"; 13 13 import { EditorState, TextSelection, Plugin } from "prosemirror-state"; 14 14 import { EditorView } from "prosemirror-view"; 15 - import { Schema, MarkSpec } from "prosemirror-model"; 15 + import { Schema, MarkSpec, Mark } from "prosemirror-model"; 16 16 import { baseKeymap } from "prosemirror-commands"; 17 17 import { keymap } from "prosemirror-keymap"; 18 18 import { history, undo, redo } from "prosemirror-history"; ··· 507 507 * Extracts mentions, links, and hashtags from the editor state and returns them 508 508 * as an array of Bluesky richtext facets with proper byte positions. 509 509 */ 510 - export function editorStateToFacets( 510 + export function editorStateToFacetedText( 511 511 state: EditorState, 512 - ): AppBskyRichtextFacet.Main[] { 513 - const facets: AppBskyRichtextFacet.Main[] = []; 514 - const fullText = state.doc.textContent; 515 - const unicodeString = new UnicodeString(fullText); 516 - 512 + ): [string, AppBskyRichtextFacet.Main[]] { 513 + let fullText = ""; 514 + let facets: AppBskyRichtextFacet.Main[] = []; 517 515 let byteOffset = 0; 518 516 519 - // Walk through the document to extract marks with their positions 520 - state.doc.descendants((node, pos) => { 521 - if (node.isText && node.text) { 522 - const text = node.text; 523 - const textLength = new UnicodeString(text).length; 517 + // Iterate through each paragraph in the document 518 + state.doc.forEach((paragraph) => { 519 + if (paragraph.type.name !== "paragraph") return; 524 520 525 - // Check for mention mark 526 - const mentionMark = node.marks.find((m) => m.type.name === "mention"); 527 - if (mentionMark) { 528 - facets.push({ 529 - index: { 530 - byteStart: byteOffset, 531 - byteEnd: byteOffset + textLength, 532 - }, 533 - features: [ 534 - { 535 - $type: "app.bsky.richtext.facet#mention", 536 - did: mentionMark.attrs.did, 521 + // Process each inline node in the paragraph 522 + paragraph.forEach((node) => { 523 + if (node.isText) { 524 + const text = node.text || ""; 525 + const unicodeString = new UnicodeString(text); 526 + 527 + // If this text node has marks, create a facet 528 + if (node.marks.length > 0) { 529 + const facet: AppBskyRichtextFacet.Main = { 530 + index: { 531 + byteStart: byteOffset, 532 + byteEnd: byteOffset + unicodeString.length, 537 533 }, 538 - ], 539 - }); 534 + features: marksToFeatures(node.marks), 535 + }; 536 + 537 + if (facet.features.length > 0) { 538 + facets.push(facet); 539 + } 540 + } 541 + 542 + fullText += text; 543 + byteOffset += unicodeString.length; 540 544 } 545 + }); 541 546 542 - // Check for link mark 543 - const linkMark = node.marks.find((m) => m.type.name === "link"); 544 - if (linkMark) { 545 - facets.push({ 546 - index: { 547 - byteStart: byteOffset, 548 - byteEnd: byteOffset + textLength, 549 - }, 550 - features: [ 551 - { 552 - $type: "app.bsky.richtext.facet#link", 553 - uri: linkMark.attrs.href, 554 - }, 555 - ], 547 + // Add newline between paragraphs (except after the last one) 548 + if (paragraph !== state.doc.lastChild) { 549 + const newline = "\n"; 550 + const unicodeNewline = new UnicodeString(newline); 551 + fullText += newline; 552 + byteOffset += unicodeNewline.length; 553 + } 554 + }); 555 + 556 + return [fullText, facets]; 557 + } 558 + 559 + function marksToFeatures(marks: readonly Mark[]) { 560 + const features: AppBskyRichtextFacet.Main["features"] = []; 561 + 562 + for (const mark of marks) { 563 + switch (mark.type.name) { 564 + case "mention": { 565 + features.push({ 566 + $type: "app.bsky.richtext.facet#mention", 567 + did: mark.attrs.did, 556 568 }); 569 + break; 557 570 } 558 - 559 - // Check for hashtag mark 560 - const hashtagMark = node.marks.find((m) => m.type.name === "hashtag"); 561 - if (hashtagMark) { 562 - facets.push({ 563 - index: { 564 - byteStart: byteOffset, 565 - byteEnd: byteOffset + textLength, 566 - }, 567 - features: [ 568 - { 569 - $type: "app.bsky.richtext.facet#tag", 570 - tag: hashtagMark.attrs.tag, 571 - }, 572 - ], 571 + case "hashtag": { 572 + features.push({ 573 + $type: "app.bsky.richtext.facet#tag", 574 + tag: mark.attrs.tag, 573 575 }); 576 + break; 574 577 } 575 - 576 - byteOffset += textLength; 578 + case "link": 579 + features.push({ 580 + $type: "app.bsky.richtext.facet#link", 581 + uri: mark.attrs.href as string, 582 + }); 583 + break; 577 584 } 578 - }); 585 + } 579 586 580 - return facets; 587 + return features; 581 588 }
+5 -5
app/[leaflet_id]/publish/PublishPost.tsx
··· 15 15 import { useReplicache } from "src/replicache"; 16 16 import { 17 17 BlueskyPostEditorProsemirror, 18 - editorStateToFacets, 18 + editorStateToFacetedText, 19 19 } from "./BskyPostEditorProsemirror"; 20 20 import { EditorState } from "prosemirror-state"; 21 21 ··· 76 76 if (!doc) return; 77 77 78 78 let post_url = `https://${props.record?.base_path}/${doc.rkey}`; 79 - let facets = editorStateRef.current 80 - ? editorStateToFacets(editorStateRef.current) 79 + let [text, facets] = editorStateRef.current 80 + ? editorStateToFacetedText(editorStateRef.current) 81 81 : []; 82 82 if (shareOption === "bluesky") 83 83 await publishPostToBsky({ 84 - facets, 85 - text: editorStateRef.current?.doc.textContent || "", 84 + facets: facets || [], 85 + text: text || "", 86 86 title: props.title, 87 87 url: post_url, 88 88 description: props.description,