diff --git a/components/dashboard/project/ExportProject.tsx b/components/dashboard/project/ExportProject.tsx index e450ef4..bb586e1 100644 --- a/components/dashboard/project/ExportProject.tsx +++ b/components/dashboard/project/ExportProject.tsx @@ -50,6 +50,9 @@ const ExportProject = () => { const [format, setFormat] = useState(ExportFormat.PDF); const [includeWatermark, setIncludeWatermark] = useState(false); + const [watermarkText, setWatermarkText] = useState( + membership?.project.author || localAuthor || "" + ); const [includeNotes, setIncludeNotes] = useState(false); const [enablePassword, setEnablePassword] = useState(false); const [password, setPassword] = useState(""); @@ -112,7 +115,7 @@ const ExportProject = () => { const pdfOptions: PDFExportOptions = { ...baseOptions, format: pageFormat === "A4" ? "A4" : "LETTER", - watermark: includeWatermark, + watermarkText: includeWatermark ? (watermarkText || undefined) : undefined, password: enablePassword && password ? password : undefined, displaySceneNumbers, sceneHeadingSpacing, @@ -238,16 +241,29 @@ const ExportProject = () => { {/* Watermark Toggle (PDF Only) */} {format === ExportFormat.PDF && (
setIncludeWatermark(!includeWatermark)} > -
- {includeWatermark &&
} -
-
- {t("watermark")} - {t("watermarkDesc")} +
+
+ {includeWatermark &&
} +
+
+ {t("watermark")} + {t("watermarkDesc")} +
+ {includeWatermark && ( + setWatermarkText(e.target.value)} + placeholder={t("watermarkPlaceholder")} + className={`${sharedStyles.input} ${styles.passwordInput}`} + onClick={(e) => e.stopPropagation()} + /> + )}
)} diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index d5fe019..d5115cb 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -17,6 +17,7 @@ import { useUser } from "@src/lib/utils/hooks"; import CommentCards from "@components/editor/CommentCards"; import Loading from "@components/utils/Loading"; +import { TextSelection } from "@tiptap/pm/state"; import { DocumentEditorConfig } from "@src/lib/editor/document-editor-config"; import { useDocumentComments } from "@src/lib/editor/use-document-comments"; import { useDocumentEditor } from "@src/lib/editor/use-document-editor"; @@ -274,6 +275,43 @@ const DocumentEditorPanel = ({ return true; } + if (currNode === ScreenplayElement.Dialogue && nodePos > 0 && nodePos < nodeSize) { + const doc = view.state.doc; + const $anchor = selection.$anchor; + + // Find the nearest preceding Character node + let charName = ""; + for (let i = $anchor.index(0) - 1; i >= 0; i--) { + const child = doc.child(i); + if (child.attrs.class === ScreenplayElement.Character) { + charName = child.textContent; + break; + } + if (child.attrs.class !== ScreenplayElement.Parenthetical && child.attrs.class !== ScreenplayElement.Dialogue) break; + } + + const schema = view.state.schema; + const secondHalf = node.content.cut(nodePos); + + const charNode = schema.nodes[ScreenplayElement.Character].create( + { class: ScreenplayElement.Character, height: null }, + charName ? schema.text(charName) : undefined, + ); + const newDialogue = schema.nodes[ScreenplayElement.Dialogue].create( + { class: ScreenplayElement.Dialogue, height: null }, + secondHalf.size > 0 ? secondHalf : undefined, + ); + + const tr = view.state.tr; + tr.delete($anchor.pos, $anchor.end(1)); + const insertPos = tr.mapping.map($anchor.after(1)); + tr.insert(insertPos, [charNode, newDialogue]); + tr.setSelection(TextSelection.create(tr.doc, insertPos + charNode.nodeSize + 1)); + tr.scrollIntoView(); + view.dispatch(tr); + return true; + } + if (nodePos < nodeSize) return false; let newNode = ScreenplayElement.Action; diff --git a/components/editor/SuggestionMenu.tsx b/components/editor/SuggestionMenu.tsx index b51f549..b33993a 100644 --- a/components/editor/SuggestionMenu.tsx +++ b/components/editor/SuggestionMenu.tsx @@ -3,7 +3,8 @@ import { useContext, useEffect, useState, useCallback, useRef } from "react"; import styles from "./SuggestionMenu.module.css"; -import { pasteTextAt } from "@src/lib/screenplay/editor"; +import { pasteTextAt, insertElement } from "@src/lib/screenplay/editor"; +import { ScreenplayElement } from "@src/lib/utils/enums"; import { ProjectContext } from "@src/context/ProjectContext"; type Props = { @@ -79,6 +80,14 @@ const SuggestionMenu = ({ suggestionData, suggestions, onSelect }: Props) => { pasteTextAt(editor, suggestion, data.cursor); onSelect?.(); } + + if (data.nodeType === "character") { + const afterPos = editor.state.selection.$anchor.after(); + const nextNode = editor.state.doc.nodeAt(afterPos); + if (!nextNode || nextNode.attrs.class !== ScreenplayElement.Dialogue) { + insertElement(editor, ScreenplayElement.Dialogue, afterPos); + } + } }, [editor, onSelect], ); @@ -97,10 +106,8 @@ const SuggestionMenu = ({ suggestionData, suggestions, onSelect }: Props) => { e.stopImmediatePropagation(); setSelectedIdx((prev) => (prev + 1) % len); } else if (e.key === "Enter") { - if (suggestionDataRef.current.nodeType !== "character") { - e.preventDefault(); - e.stopImmediatePropagation(); - } + e.preventDefault(); + e.stopImmediatePropagation(); selectSuggestion(selectedIdxRef.current); } }; diff --git a/components/project/ProjectWorkspace.tsx b/components/project/ProjectWorkspace.tsx index 6a4f4bf..45dec5b 100644 --- a/components/project/ProjectWorkspace.tsx +++ b/components/project/ProjectWorkspace.tsx @@ -35,7 +35,7 @@ const ProjectWorkspace = () => {
{/* Overlays */} - {suggestions.length > 0 && } + {suggestions.length > 0 && updateSuggestions([])} />} {/* Left sidebar - only show when screenplay is visible */} diff --git a/messages/de.json b/messages/de.json index 7644cb8..38eec5d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -227,7 +227,8 @@ "readable": "Menschenlesbares JSON", "readableDesc": "Als einfaches JSON statt komprimiertem Binärformat exportieren. Größere Datei, mit jedem Texteditor inspizierbar.", "watermark": "Wasserzeichen", - "watermarkDesc": "Den Namen des Autors auf den Seiten einblenden.", + "watermarkDesc": "Text auf den Seiten einblenden.", + "watermarkPlaceholder": "Wasserzeichen-Text", "passwordProtection": "Kennwortschutz", "passwordProtectionDesc": "Ein Kennwort zum Öffnen der PDF-Datei erforderlich.", "passwordPlaceholder": "Kennwort eingeben", diff --git a/messages/en.json b/messages/en.json index 3b3265c..c2b0491 100644 --- a/messages/en.json +++ b/messages/en.json @@ -227,7 +227,8 @@ "readable": "Human-readable JSON", "readableDesc": "Export as plain JSON instead of compressed binary. Larger file, inspectable with any text editor.", "watermark": "Watermark", - "watermarkDesc": "Overlay the author's name on pages.", + "watermarkDesc": "Overlay text on pages.", + "watermarkPlaceholder": "Watermark text", "passwordProtection": "Password Protection", "passwordProtectionDesc": "Require a password to open the PDF.", "passwordPlaceholder": "Enter password", diff --git a/messages/es.json b/messages/es.json index 591c1cb..ae7c0d5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -227,7 +227,8 @@ "readable": "JSON legible", "readableDesc": "Exportar como JSON plano en lugar de binario comprimido. Archivo más grande, inspeccionable con cualquier editor de texto.", "watermark": "Marca de agua", - "watermarkDesc": "Superponer el nombre del autor en las páginas.", + "watermarkDesc": "Superponer texto en las páginas.", + "watermarkPlaceholder": "Texto de marca de agua", "passwordProtection": "Protección con contraseña", "passwordProtectionDesc": "Requerir una contraseña para abrir el PDF.", "passwordPlaceholder": "Introduce la contraseña", diff --git a/messages/fr.json b/messages/fr.json index bd358fd..20f360c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -227,7 +227,8 @@ "readable": "JSON lisible", "readableDesc": "Exporter en JSON brut plutôt qu'en binaire compressé. Fichier plus volumineux, inspectable avec n'importe quel éditeur de texte.", "watermark": "Filigrane", - "watermarkDesc": "Superposer le nom de l'auteur sur les pages.", + "watermarkDesc": "Superposer du texte sur les pages.", + "watermarkPlaceholder": "Texte du filigrane", "passwordProtection": "Protection par mot de passe", "passwordProtectionDesc": "Exiger un mot de passe pour ouvrir le PDF.", "passwordPlaceholder": "Entrer le mot de passe", diff --git a/messages/ja.json b/messages/ja.json index 1599482..96cf6a8 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -227,7 +227,8 @@ "readable": "人間が読めるJSON", "readableDesc": "圧縮バイナリの代わりにプレーンJSONでエクスポートします。ファイルサイズは大きくなりますが、任意のテキストエディタで確認できます。", "watermark": "透かし", - "watermarkDesc": "ページに著者名を重ねて表示します。", + "watermarkDesc": "ページにテキストを重ねて表示します。", + "watermarkPlaceholder": "透かしテキスト", "passwordProtection": "パスワード保護", "passwordProtectionDesc": "PDFを開くためにパスワードが必要です。", "passwordPlaceholder": "パスワードを入力", diff --git a/messages/ko.json b/messages/ko.json index 63e7047..bf6cd9f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -227,7 +227,8 @@ "readable": "사람이 읽을 수 있는 JSON", "readableDesc": "압축 바이너리 대신 일반 JSON으로 내보냅니다. 파일 크기가 크지만 어떤 텍스트 편집기로도 열어볼 수 있습니다.", "watermark": "워터마크", - "watermarkDesc": "페이지에 작가 이름을 오버레이합니다.", + "watermarkDesc": "페이지에 텍스트를 오버레이합니다.", + "watermarkPlaceholder": "워터마크 텍스트", "passwordProtection": "비밀번호 보호", "passwordProtectionDesc": "PDF를 열 때 비밀번호가 필요합니다.", "passwordPlaceholder": "비밀번호 입력", diff --git a/messages/pl.json b/messages/pl.json index 40b479b..41707f9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -227,7 +227,8 @@ "readable": "Czytelny JSON", "readableDesc": "Eksportuj jako zwykły JSON zamiast skompresowanego formatu binarnego. Większy plik, możliwy do podglądu w dowolnym edytorze tekstu.", "watermark": "Znak wodny", - "watermarkDesc": "Nakładanie nazwy autora na strony.", + "watermarkDesc": "Nakładanie tekstu na strony.", + "watermarkPlaceholder": "Tekst znaku wodnego", "passwordProtection": "Ochrona hasłem", "passwordProtectionDesc": "Wymagaj hasła do otwarcia pliku PDF.", "passwordPlaceholder": "Wprowadź hasło", diff --git a/messages/zh.json b/messages/zh.json index 8b54995..f4e4b0f 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -227,7 +227,8 @@ "readable": "人类可读的 JSON", "readableDesc": "以纯 JSON 格式导出,而非压缩二进制格式。文件较大,可用任何文本编辑器查看。", "watermark": "水印", - "watermarkDesc": "在页面上叠加作者姓名。", + "watermarkDesc": "在页面上叠加文本。", + "watermarkPlaceholder": "水印文字", "passwordProtection": "密码保护", "passwordProtectionDesc": "需要密码才能打开 PDF。", "passwordPlaceholder": "输入密码", diff --git a/src/lib/adapters/pdf/pdf-adapter.ts b/src/lib/adapters/pdf/pdf-adapter.ts index 7515e93..8d5c1f0 100644 --- a/src/lib/adapters/pdf/pdf-adapter.ts +++ b/src/lib/adapters/pdf/pdf-adapter.ts @@ -10,7 +10,7 @@ import { PAGE_SIZES } from "@src/lib/screenplay/extensions/pagination-extension" export type PDFExportOptions = BaseExportOptions & { format: PageFormat; - watermark: boolean; + watermarkText?: string; password?: string; displaySceneNumbers?: boolean; sceneHeadingBold?: boolean; @@ -105,7 +105,7 @@ export class PDFAdapter extends ProjectAdapter { baseUrl: BASE_URL, pageWidth: pdfPageSize.width, pageHeight: pdfPageSize.height, - watermark: options.watermark, + watermarkText: options.watermarkText, password: options.password, author: options.author, titlePageLines, diff --git a/src/lib/adapters/pdf/pdf.worker.ts b/src/lib/adapters/pdf/pdf.worker.ts index 4a1110e..5ca2e44 100644 --- a/src/lib/adapters/pdf/pdf.worker.ts +++ b/src/lib/adapters/pdf/pdf.worker.ts @@ -149,7 +149,7 @@ export interface WorkerPayload { baseUrl: string; pageWidth: number; // PDF page width in points pageHeight: number; // PDF page height in points - watermark: boolean; + watermarkText?: string; password?: string; author: string; titlePageLines: VisualLine[]; @@ -276,7 +276,7 @@ async function renderLines( } // Watermark on the page we are leaving - if (payload.watermark) drawWatermark(doc, pageSize, payload.author); + if (payload.watermarkText) drawWatermark(doc, pageSize, payload.watermarkText); doc.addPage(); currentPage++; @@ -370,7 +370,7 @@ async function renderLines( } } - if (payload.watermark) drawWatermark(doc, pageSize, payload.author); + if (payload.watermarkText) drawWatermark(doc, pageSize, payload.watermarkText); } /** @@ -433,10 +433,22 @@ function drawWatermark(doc: jsPDF, pageSize: { width: number; height: number }, doc.setFont("CourierPrime", "bold"); doc.setFontSize(54); doc.setTextColor(128, 128, 128); - doc.text(text, pageSize.width / 2, pageSize.height / 2, { - align: "center", - baseline: "middle", - angle: 45, - }); + + // jsPDF shifts x by -textWidth/2 for align:"center" *before* applying the rotation, + // so the rotation pivot ends up at the text's left edge rather than the page centre. + // Instead, compute the start position manually so the visual midpoint of the + // rotated text lands at (cx, cy). + // For angle:45 (CCW = upper-right in screen space), text advances in direction + // (cos 45°, −sin 45°) in Y-down page coordinates: + // cx = x0 + (textWidth/2) × cos45 → x0 = cx − (textWidth/2) × cos45 + // cy = y0 − (textWidth/2) × sin45 → y0 = cy + (textWidth/2) × sin45 + const textWidth = doc.getTextWidth(text); + const cx = pageSize.width / 2; + const cy = pageSize.height / 2; + const rad = Math.PI / 4; + const x0 = cx - (textWidth / 2) * Math.cos(rad); + const y0 = cy + (textWidth / 2) * Math.sin(rad); + + doc.text(text, x0, y0, { angle: 45 }); doc.restoreGraphicsState(); } diff --git a/src/lib/editor/use-document-editor.ts b/src/lib/editor/use-document-editor.ts index bceb6a9..9c554c9 100644 --- a/src/lib/editor/use-document-editor.ts +++ b/src/lib/editor/use-document-editor.ts @@ -4,6 +4,7 @@ import { useCallback, useContext, useEffect, useRef } from "react"; import { Editor, useEditor } from "@tiptap/react"; import Collaboration from "@tiptap/extension-collaboration"; import CollaborationCaret from "@tiptap/extension-collaboration-caret"; +import { ySyncPluginKey, yUndoPluginKey } from "@tiptap/y-tiptap"; import { ProjectContext } from "@src/context/ProjectContext"; import { ScreenplayElement, Style, TitlePageElement } from "@src/lib/utils/enums"; @@ -438,6 +439,44 @@ export const useDocumentEditor = ( } }, [user?.username, user?.color, provider]); + // Fix Yjs undo cursor restoration: y-tiptap's stack-item-popped fires AFTER + // the undo transaction commits, so beforeTransactionSelection is captured wrong + // by beforeAllTransactions. Patch undo/redo to pre-set it from the stack item. + useEffect(() => { + if (!editor || !isYjsReady) return; + + const state = editor.state; + const yUndoState = yUndoPluginKey.getState(state); + const ySyncState = ySyncPluginKey.getState(state); + if (!yUndoState?.undoManager || !ySyncState?.binding) return; + + const um = yUndoState.undoManager; + const binding = ySyncState.binding; + const originalUndo = um.undo.bind(um); + const originalRedo = um.redo.bind(um); + + um.undo = () => { + if (um.undoStack.length > 0) { + const prevSel = um.undoStack[um.undoStack.length - 1].meta.get(binding); + if (prevSel) binding.beforeTransactionSelection = prevSel; + } + return originalUndo(); + }; + + um.redo = () => { + if (um.redoStack.length > 0) { + const prevSel = um.redoStack[um.redoStack.length - 1].meta.get(binding); + if (prevSel) binding.beforeTransactionSelection = prevSel; + } + return originalRedo(); + }; + + return () => { + um.undo = originalUndo; + um.redo = originalRedo; + }; + }, [editor, isYjsReady]); + // Refresh character highlights useEffect(() => { if (editor && features.characterHighlights) { diff --git a/src/lib/screenplay/extensions/pagination-extension.ts b/src/lib/screenplay/extensions/pagination-extension.ts index 1f0a60a..0d0508a 100644 --- a/src/lib/screenplay/extensions/pagination-extension.ts +++ b/src/lib/screenplay/extensions/pagination-extension.ts @@ -546,7 +546,6 @@ const paginationKey = new PluginKey("pagination"); interface PaginationState { decset: DecorationSet; - heightUpdates: { pos: number; height: number }[]; breaks: PageBreakInfo[]; lastPageFreespace: number; } @@ -557,13 +556,11 @@ const createPaginationPlugin = (extension: any) => state: { init: (): PaginationState => ({ decset: DecorationSet.empty, - heightUpdates: [], breaks: [], lastPageFreespace: 0, }), apply(tr, value: PaginationState, oldState, newState): PaginationState { const options = extension.options as PaginationOptions; - const heightUpdate = tr.getMeta("heightUpdate"); const formatUpdate = tr.getMeta("pageFormatUpdate"); const forceUpdate = tr.getMeta("forcePaginationUpdate"); @@ -574,33 +571,15 @@ const createPaginationPlugin = (extension: any) => // Nothing pagination-related changed if (!tr.docChanged && !forceUpdate && !formatUpdate) return value; - // Heights were just committed by appendTransaction via setNodeMarkup. - // Breaks were already computed (with fresh heights) in the previous apply. - // Rebuild decorations from those pre-computed breaks — can't use map() - // because setNodeMarkup's ReplaceAroundStep destroys widget decorations - // at the replaced node's position - if (heightUpdate) { - return { - decset: buildDecorations(newState.doc, value.breaks, value.lastPageFreespace, options), - heightUpdates: [], - breaks: value.breaks, - lastPageFreespace: value.lastPageFreespace, - }; - } - const fullRemeasure = forceUpdate || formatUpdate; - // Determine changed node positions from step maps - const changedPositions = new Set(); + // Track the furthest changed position for the short-circuit break optimization let maxChangedPos = -1; if (tr.docChanged && !fullRemeasure) { tr.steps.forEach((step) => { const map = step.getMap(); - map.forEach((_oS: number, _oE: number, newStart: number, newEnd: number) => { + map.forEach((_oS: number, _oE: number, _newStart: number, newEnd: number) => { if (newEnd > maxChangedPos) maxChangedPos = newEnd; - newState.doc.nodesBetween(newStart, newEnd, (_node, pos) => { - changedPositions.add(pos); - }); }); }); } @@ -612,7 +591,7 @@ const createPaginationPlugin = (extension: any) => const oldBreakByPos = new Map(); mappedOldBreaks.forEach((b, i) => oldBreakByPos.set(b.pos, { info: b, index: i })); - // --- Single pass: measure dirty heights + compute page breaks --- + // --- Single pass: measure heights + compute page breaks --- let editor = extension.editor as Editor; if (!editor.isInitialized || !extension.editor.view?.dom) return value; @@ -638,7 +617,6 @@ const createPaginationPlugin = (extension: any) => if (_snp) options.startNewPageTypes = new Set(JSON.parse(_snp)); const serializer = DOMSerializer.fromSchema(newState.schema); - const heightUpdates: { pos: number; height: number }[] = []; const contentHeight = options.pageHeight - options.marginTop - options.marginBottom; const breaks: PageBreakInfo[] = []; @@ -662,19 +640,17 @@ const createPaginationPlugin = (extension: any) => const nodeType = node.type.name as ScreenplayElement; const logic = BREAK_LOGIC[nodeType]; - // Use cached height or measure if dirty. - // element is hoisted so the overflow block can reuse it for split measurement - // without serialising the node a second time. - let height = node.attrs.height as number | null; - const isDirty = fullRemeasure || height === null || changedPositions.has(pos); + // Use the module-level heightCache (keyed by content) to avoid re-serializing + // unchanged nodes. Cache misses (new/edited content) trigger serialization. + // element is hoisted so the split block can reuse it without a second serialize. + const textContent = node.textContent || ""; + const cacheKey = `${node.type.name}:${options.pageWidth}:${options.marginLeft}:${options.marginRight}:${textContent}`; + let height = heightCache.get(cacheKey) ?? null; let element: HTMLElement | null = null; - if (isDirty) { + if (height === null) { element = serializer.serializeNode(node) as HTMLElement; height = getHTMLHeight(element, editorDOM, node.type.name, options); - if (height !== node.attrs.height) { - heightUpdates.push({ pos, height }); - } } if (height == null) continue; @@ -719,7 +695,7 @@ const createPaginationPlugin = (extension: any) => freespaceBeforeNode > MIN_SPLIT_FREESPACE && height > logic.minSplitHeight ) { - // Serialize lazily — only needed here when the node was not already dirty. + // Serialize lazily — only needed here when not already serialized above. if (!element) element = serializer.serializeNode(node) as HTMLElement; const split = trySplitNode(node, pos, freespaceBeforeNode, element, editorDOM, options); @@ -812,24 +788,9 @@ const createPaginationPlugin = (extension: any) => } // Compute remaining space on the last page so the last-page widget - // can pad it to full page height (mirrors how page-break widgets - // account for freespace on every other page). + // can pad it to full page height. const lastPageFreespace = Math.max(0, contentHeight - pagePos); - // Heights need committing — appendTransaction will fire setNodeMarkup, - // then the heightUpdate apply will rebuild decorations from these breaks. - // Just remap old decorations for now (never rendered — view updates only - // after all transactions complete). - if (heightUpdates.length > 0) { - return { - decset: value.decset.map(tr.mapping, tr.doc), - heightUpdates, - breaks, - lastPageFreespace, - }; - } - - // No pending height commits — this is the final state the view will render. // Check if breaks actually changed compared to mapped old breaks. const breaksChanged = fullRemeasure || @@ -845,40 +806,10 @@ const createPaginationPlugin = (extension: any) => ? buildDecorations(newState.doc, breaks, lastPageFreespace, options) : value.decset.map(tr.mapping, tr.doc); - return { decset, heightUpdates: [], breaks, lastPageFreespace }; + return { decset, breaks, lastPageFreespace }; }, }, - appendTransaction(transactions, oldState, newState) { - const state = paginationKey.getState(newState) as PaginationState | undefined; - if (!state?.heightUpdates.length) return; - - // Skip processing for remote Yjs transactions - const isRemoteChange = transactions.some((tr) => tr.getMeta("y-sync$")); - if (isRemoteChange) { - return null; - } - - const tr = newState.tr; - tr.setMeta("heightUpdate", true); - - // If the original transactions were meant to be excluded from history (e.g. undo/redo), - // ensure the height updates are also excluded so they don't pollute the history stack. - if (transactions.some((t) => t.getMeta("addToHistory") === false)) { - tr.setMeta("addToHistory", false); - } - - state.heightUpdates.forEach(({ pos, height }) => { - const mappedPos = tr.mapping.map(pos); - const node = tr.doc.nodeAt(mappedPos); - if (node && node.attrs.height !== height) { - tr.setNodeMarkup(mappedPos, undefined, { ...node.attrs, height }); - } - }); - - if (tr.steps.length) { - return tr; - } - + appendTransaction() { return null; }, props: {