From 3de584c25cf7145aac88fc12bc832448ec130c6a Mon Sep 17 00:00:00 2001 From: Nowely Date: Wed, 11 Mar 2026 15:04:42 +0300 Subject: [PATCH 1/8] feat(drag): introduce drag-and-drop functionality for token management - Added support for drag mode where each token (mark or text) is treated as an individual draggable row. - Implemented new utilities for managing drag rows: `addDragRow`, `deleteDragRow`, `duplicateDragRow`, `mergeDragRows`, and `reorderDragRows`. - Enhanced existing block operations to accommodate drag mode, including conditional handling in the `KeyDownController` and `BlockContainer` components. - Updated `MarkedInput` and `BlockContainer` components to support drag mode via a new `drag` prop. - Introduced new utility functions for handling drag-specific behaviors, including separator management between rows. - Added stories to demonstrate drag functionality in the Storybook. --- packages/common/core/index.ts | 8 + .../common/core/src/features/blocks/config.ts | 4 + .../src/features/blocks/dragOperations.ts | 140 ++++++++++++ .../common/core/src/features/blocks/index.ts | 15 +- .../features/blocks/splitTokensIntoBlocks.ts | 4 +- .../blocks/splitTokensIntoDragRows.ts | 97 ++++++++ .../src/features/input/KeyDownController.ts | 166 ++++++++++++-- .../common/core/src/features/store/Store.ts | 1 + packages/common/core/src/shared/types.ts | 1 + .../markput/src/components/BlockContainer.tsx | 44 +++- .../markput/src/components/MarkedInput.tsx | 9 +- .../storybook/src/pages/Drag/Drag.stories.tsx | 215 ++++++++++++++++++ .../markput/src/components/BlockContainer.vue | 38 +++- .../markput/src/components/MarkedInput.vue | 5 +- packages/vue/markput/src/types.ts | 4 + 15 files changed, 709 insertions(+), 42 deletions(-) create mode 100644 packages/common/core/src/features/blocks/dragOperations.ts create mode 100644 packages/common/core/src/features/blocks/splitTokensIntoDragRows.ts create mode 100644 packages/react/storybook/src/pages/Drag/Drag.stories.tsx diff --git a/packages/common/core/index.ts b/packages/common/core/index.ts index 441a3ed6..c9bf6c76 100644 --- a/packages/common/core/index.ts +++ b/packages/common/core/index.ts @@ -87,12 +87,20 @@ export {MarkHandler, type MarkOptions, type RefAccessor} from './src/features/ma // Blocks export { splitTokensIntoBlocks, + splitTokensIntoDragRows, reorderBlocks, + reorderDragRows, addBlock, + addDragRow, deleteBlock, + deleteDragRow, duplicateBlock, + duplicateDragRow, + mergeDragRows, + getMergeDragRowJoinPos, BLOCK_SEPARATOR, getAlwaysShowHandle, + getAlwaysShowHandleDrag, type Block, } from './src/features/blocks' diff --git a/packages/common/core/src/features/blocks/config.ts b/packages/common/core/src/features/blocks/config.ts index 7aa6862b..fae19552 100644 --- a/packages/common/core/src/features/blocks/config.ts +++ b/packages/common/core/src/features/blocks/config.ts @@ -2,4 +2,8 @@ export const BLOCK_SEPARATOR = '\n\n' export function getAlwaysShowHandle(block: boolean | {alwaysShowHandle: boolean}): boolean { return typeof block === 'object' && !!block.alwaysShowHandle +} + +export function getAlwaysShowHandleDrag(drag: boolean | {alwaysShowHandle: boolean}): boolean { + return typeof drag === 'object' && !!drag.alwaysShowHandle } \ No newline at end of file diff --git a/packages/common/core/src/features/blocks/dragOperations.ts b/packages/common/core/src/features/blocks/dragOperations.ts new file mode 100644 index 00000000..619d2834 --- /dev/null +++ b/packages/common/core/src/features/blocks/dragOperations.ts @@ -0,0 +1,140 @@ +import {BLOCK_SEPARATOR} from './config' +import type {Block} from './splitTokensIntoBlocks' + +/** + * In drag mode the gap between two adjacent rows can be: + * 0 — marks are auto-delimited by their syntax (no separator needed) + * 2 — `\n\n` required between two text rows + * + * These helpers mirror the `blockOperations` API but account for 0-gap adjacency. + */ + +function rowGap(rows: Block[], index: number): number { + if (index >= rows.length - 1) return 0 + return rows[index + 1].startPos - rows[index].endPos +} + +function isTextRow(row: Block): boolean { + return row.tokens.length === 0 || row.tokens[0].type === 'text' +} + +/** + * Returns the separator that should sit between row[a] and row[b] in the value. + * Text-text pairs need `\n\n`; everything else is adjacent with no separator. + */ +function separatorBetween(a: Block, b: Block): string { + return isTextRow(a) && isTextRow(b) ? BLOCK_SEPARATOR : '' +} + +export function addDragRow(value: string, rows: Block[], afterIndex: number): string { + if (rows.length === 0) return value + BLOCK_SEPARATOR + + // Last row: append `\n\n` — splitTokensIntoDragRows will create a trailing empty text row. + if (afterIndex >= rows.length - 1) { + return value + BLOCK_SEPARATOR + } + + const curr = rows[afterIndex] + const next = rows[afterIndex + 1] + const gap = next.startPos - curr.endPos + + if (gap === 0) { + // Marks are adjacent (no separator) — insert `\n\n\n\n` to carve out a visible empty slot: + // the double-separator will produce an empty text row between the two `\n\n` boundaries. + return value.slice(0, curr.endPos) + BLOCK_SEPARATOR + BLOCK_SEPARATOR + value.slice(next.startPos) + } + + // gap = 2 (existing `\n\n`): insert one more `\n\n` at next.startPos → `\n\n\n\n` gap → empty row. + return value.slice(0, next.startPos) + BLOCK_SEPARATOR + value.slice(next.startPos) +} + +export function deleteDragRow(value: string, rows: Block[], index: number): string { + if (rows.length <= 1) return '' + + if (index >= rows.length - 1) { + // Last row: trim back to previous row's endPos. + return value.slice(0, rows[index - 1].endPos) + } + + // Remove from this row's startPos to the next row's startPos. + // This removes the row content and any gap (0 or `\n\n`) after it. + // The gap before it (from prev to curr) becomes the new gap between prev and next. + return value.slice(0, rows[index].startPos) + value.slice(rows[index + 1].startPos) +} + +export function duplicateDragRow(value: string, rows: Block[], index: number): string { + const row = rows[index] + const rowText = value.substring(row.startPos, row.endPos) + + if (index >= rows.length - 1) { + const sep = isTextRow(row) ? BLOCK_SEPARATOR : BLOCK_SEPARATOR + return value + sep + rowText + } + + const next = rows[index + 1] + const gap = next.startPos - row.endPos + const sep = gap === 0 ? '' : BLOCK_SEPARATOR + // Insert: rowText + appropriate separator + (existing gap preserved in slice) + return value.slice(0, next.startPos) + rowText + sep + value.slice(next.startPos) +} + +/** + * Returns the raw-value position of the join point between row[index-1] and row[index] + * for use as the caret position after a merge. + * Only meaningful for text-text merges (gap = 2). + */ +export function getMergeDragRowJoinPos(rows: Block[], index: number): number { + if (index <= 0 || index >= rows.length) return 0 + return rows[index - 1].endPos +} + +/** + * Merges row[index] into row[index - 1] by removing the `\n\n` separator between them. + * Only has an effect when both rows are text rows (gap = 2). + */ +export function mergeDragRows(value: string, rows: Block[], index: number): string { + if (index <= 0 || index >= rows.length) return value + const prev = rows[index - 1] + const curr = rows[index] + // Remove everything between prev.endPos and curr.startPos (the `\n\n` separator). + return value.slice(0, prev.endPos) + value.slice(curr.startPos) +} + +/** + * Reorders rows by moving the row at `sourceIndex` to `targetIndex`. + * After reordering, separator between adjacent rows is determined by their types: + * text + text → `\n\n` + * anything else → `""` (marks are auto-delimited) + */ +export function reorderDragRows(value: string, rows: Block[], sourceIndex: number, targetIndex: number): string { + if (sourceIndex === targetIndex || sourceIndex === targetIndex - 1) return value + if (rows.length < 2) return value + if (sourceIndex < 0 || sourceIndex >= rows.length) return value + if (targetIndex < 0 || targetIndex > rows.length) return value + + // Extract raw text for each row + const texts = rows.map(row => value.substring(row.startPos, row.endPos)) + + // Reorder + const reordered = [...rows] + const [movedRow] = reordered.splice(sourceIndex, 1) + const [movedText] = texts.splice(sourceIndex, 1) + + const insertAt = targetIndex > sourceIndex ? targetIndex - 1 : targetIndex + reordered.splice(insertAt, 0, movedRow) + texts.splice(insertAt, 0, movedText) + + // Reassemble with correct separators + const parts: string[] = [] + for (let i = 0; i < texts.length; i++) { + parts.push(texts[i]) + if (i < texts.length - 1) { + parts.push(separatorBetween(reordered[i], reordered[i + 1])) + } + } + + return parts.join('') +} + +// Re-export gap helper for use in KeyDownController +export {rowGap, isTextRow} \ No newline at end of file diff --git a/packages/common/core/src/features/blocks/index.ts b/packages/common/core/src/features/blocks/index.ts index 9e5e1503..fc947dcb 100644 --- a/packages/common/core/src/features/blocks/index.ts +++ b/packages/common/core/src/features/blocks/index.ts @@ -1,4 +1,13 @@ -export {splitTokensIntoBlocks, type Block} from './splitTokensIntoBlocks' +export {splitTokensIntoBlocks, type Block, splitTextByBlockSeparator, type TextPart} from './splitTokensIntoBlocks' +export {splitTokensIntoDragRows} from './splitTokensIntoDragRows' export {reorderBlocks} from './reorderBlocks' -export {addBlock, deleteBlock, duplicateBlock, mergeBlocks} from './blockOperations' -export {BLOCK_SEPARATOR, getAlwaysShowHandle} from './config' \ No newline at end of file +export {addBlock, deleteBlock, duplicateBlock, mergeBlocks, getMergeJoinPos} from './blockOperations' +export { + addDragRow, + deleteDragRow, + duplicateDragRow, + mergeDragRows, + getMergeDragRowJoinPos, + reorderDragRows, +} from './dragOperations' +export {BLOCK_SEPARATOR, getAlwaysShowHandle, getAlwaysShowHandleDrag} from './config' \ No newline at end of file diff --git a/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts b/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts index 1a86eb25..5cc391d1 100644 --- a/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts +++ b/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts @@ -100,13 +100,13 @@ export function splitTokensIntoBlocks(tokens: Token[]): Block[] { return blocks } -interface TextPart { +export interface TextPart { content: string position: {start: number; end: number} isBlockSeparator: boolean } -function splitTextByBlockSeparator(token: TextToken): TextPart[] { +export function splitTextByBlockSeparator(token: TextToken): TextPart[] { const parts: TextPart[] = [] const {content, position} = token diff --git a/packages/common/core/src/features/blocks/splitTokensIntoDragRows.ts b/packages/common/core/src/features/blocks/splitTokensIntoDragRows.ts new file mode 100644 index 00000000..e40a784c --- /dev/null +++ b/packages/common/core/src/features/blocks/splitTokensIntoDragRows.ts @@ -0,0 +1,97 @@ +import type {Token, TextToken} from '../parsing/ParserV2/types' +import {splitTextByBlockSeparator} from './splitTokensIntoBlocks' +import type {Block} from './splitTokensIntoBlocks' + +let dragRowIdCounter = 0 + +function generateRowId(startPos: number): string { + return `drag-${dragRowIdCounter++}-${startPos}` +} + +export function resetDragRowIdCounter(): void { + dragRowIdCounter = 0 +} + +/** + * Splits a flat token list into drag rows where each top-level token = one row. + * + * Unlike `splitTokensIntoBlocks` (which groups multiple tokens per block, separated by `\n\n`), + * this function makes each individual token its own row: + * - MarkToken → one row (auto-delimited by mark syntax, no `\n\n` needed between marks) + * - TextToken → split by `\n\n` → one row per non-separator fragment + * + * Separator rules: + * - Adjacent marks: gap = 0 (no `\n\n` needed) + * - Adjacent text rows: gap = 2 (`\n\n` required in value) + * - Mark + text or text + mark: gap = 0 + * - A trailing `\n\n` creates an empty text row at the end + * - Two consecutive `\n\n` in a text region creates an empty text row between them + */ +export function splitTokensIntoDragRows(tokens: Token[]): Block[] { + if (tokens.length === 0) return [] + + resetDragRowIdCounter() + + const rows: Block[] = [] + + // Tracks the position right after the last `\n\n` separator seen inside a text token. + // Used to detect consecutive separators (→ empty text row) or trailing separators. + let afterSeparatorPos: number | null = null + + for (const token of tokens) { + if (token.type === 'mark') { + // A mark absorbs any pending separator gap — no empty text row is created + // between a `\n\n` separator and the following mark. + afterSeparatorPos = null + rows.push({ + id: generateRowId(token.position.start), + tokens: [token], + startPos: token.position.start, + endPos: token.position.end, + }) + } else if (token.type === 'text') { + const parts = splitTextByBlockSeparator(token as TextToken) + for (const part of parts) { + if (part.isBlockSeparator) { + // Another separator seen while afterSeparatorPos was already set + // → the gap between two separators is an empty text row. + if (afterSeparatorPos !== null) { + rows.push({ + id: generateRowId(afterSeparatorPos), + tokens: [], + startPos: afterSeparatorPos, + endPos: afterSeparatorPos, + }) + } + afterSeparatorPos = part.position.end + } else if (part.content.length > 0) { + afterSeparatorPos = null + rows.push({ + id: generateRowId(part.position.start), + tokens: [ + { + type: 'text', + content: part.content, + position: part.position, + } satisfies TextToken, + ], + startPos: part.position.start, + endPos: part.position.end, + }) + } + } + } + } + + // A trailing `\n\n` (afterSeparatorPos still set) creates an empty text row at the end. + if (afterSeparatorPos !== null) { + rows.push({ + id: generateRowId(afterSeparatorPos), + tokens: [], + startPos: afterSeparatorPos, + endPos: afterSeparatorPos, + }) + } + + return rows +} \ No newline at end of file diff --git a/packages/common/core/src/features/input/KeyDownController.ts b/packages/common/core/src/features/input/KeyDownController.ts index 0c971e7a..d9b8ad40 100644 --- a/packages/common/core/src/features/input/KeyDownController.ts +++ b/packages/common/core/src/features/input/KeyDownController.ts @@ -2,7 +2,9 @@ import type {NodeProxy} from '../../shared/classes/NodeProxy' import {KEYBOARD} from '../../shared/constants' import {deleteBlock, getMergeJoinPos, mergeBlocks} from '../blocks/blockOperations' import {BLOCK_SEPARATOR} from '../blocks/config' +import {addDragRow, getMergeDragRowJoinPos, mergeDragRows, isTextRow} from '../blocks/dragOperations' import {splitTokensIntoBlocks, type Block} from '../blocks/splitTokensIntoBlocks' +import {splitTokensIntoDragRows} from '../blocks/splitTokensIntoDragRows' import {Caret} from '../caret' import {shiftFocusNext, shiftFocusPrev} from '../navigation' import type {MarkToken} from '../parsing/ParserV2/types' @@ -66,10 +68,11 @@ export class KeyDownController { #handleDelete(event: KeyboardEvent) { const {focus} = this.store.nodes const isBlockMode = !!this.store.state.block.get() + const isDragMode = !!this.store.state.drag.get() - // Mark/span deletion only applies in non-block mode. - // In block mode the focus target is a block div, not a span/mark. - if (!isBlockMode && (event.key === KEYBOARD.DELETE || event.key === KEYBOARD.BACKSPACE)) { + // Mark/span deletion only applies in non-block/non-drag mode. + // In block/drag mode the focus target is a block div, not a span/mark. + if (!isBlockMode && !isDragMode && (event.key === KEYBOARD.DELETE || event.key === KEYBOARD.BACKSPACE)) { if (focus.isMark) { if (focus.isEditable) { if (event.key === KEYBOARD.BACKSPACE && !focus.isCaretAtBeginning) return @@ -97,7 +100,7 @@ export class KeyDownController { } } - if (!isBlockMode) return + if (!isBlockMode && !isDragMode) return const container = this.store.refs.container if (!container) return @@ -109,7 +112,7 @@ export class KeyDownController { if (blockIndex === -1) return const tokens = this.store.state.tokens.get() - const blocks = splitTokensIntoBlocks(tokens) + const blocks = isDragMode ? splitTokensIntoDragRows(tokens) : splitTokensIntoBlocks(tokens) if (blockIndex >= blocks.length) return const block = blocks[blockIndex] @@ -124,7 +127,15 @@ export class KeyDownController { const blockText = block.tokens.map(t => ('content' in t ? (t as {content: string}).content : '')).join('') if (blockText === '') { event.preventDefault() - const newValue = deleteBlock(value, blocks, blockIndex) + const newValue = isDragMode + ? blocks.length <= 1 + ? '' + : (() => { + const b = blocks + if (blockIndex >= b.length - 1) return value.slice(0, b[blockIndex - 1].endPos) + return value.slice(0, b[blockIndex].startPos) + value.slice(b[blockIndex + 1].startPos) + })() + : deleteBlock(value, blocks, blockIndex) this.store.applyValue(newValue) queueMicrotask(() => { const newDivs = container.children @@ -140,6 +151,40 @@ export class KeyDownController { // Non-empty block at position 0: merge with previous block if (caretAtStart && blockIndex > 0) { + if (isDragMode) { + const prevBlock = blocks[blockIndex - 1] + const currBlock = blocks[blockIndex] + const gap = currBlock.startPos - prevBlock.endPos + if (isTextRow(prevBlock) && isTextRow(currBlock) && gap === 2) { + // Text-text merge: remove the \n\n separator + event.preventDefault() + const joinPos = getMergeDragRowJoinPos(blocks, blockIndex) + const newValue = mergeDragRows(value, blocks, blockIndex) + this.store.applyValue(newValue) + queueMicrotask(() => { + const newDivs = container.children + const target = newDivs[blockIndex - 1] as HTMLElement | undefined + if (target) { + target.focus() + const updatedBlocks = splitTokensIntoDragRows(this.store.state.tokens.get()) + const updatedBlock = updatedBlocks[blockIndex - 1] + if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos) + } + }) + return + } + // Previous row is a mark (or gap=0): navigate only + event.preventDefault() + queueMicrotask(() => { + const target = blockDivs[blockIndex - 1] as HTMLElement | undefined + if (target) { + target.focus() + Caret.setCaretToEnd(target) + } + }) + return + } + event.preventDefault() const joinPos = getMergeJoinPos(blocks, blockIndex) const newValue = mergeBlocks(value, blocks, blockIndex) @@ -166,6 +211,39 @@ export class KeyDownController { // Caret at start of non-first block: merge current block into previous (like Backspace at start) if (caretAtStart && blockIndex > 0) { + if (isDragMode) { + const prevBlock = blocks[blockIndex - 1] + const currBlock = blocks[blockIndex] + const gap = currBlock.startPos - prevBlock.endPos + if (isTextRow(prevBlock) && isTextRow(currBlock) && gap === 2) { + event.preventDefault() + const joinPos = getMergeDragRowJoinPos(blocks, blockIndex) + const newValue = mergeDragRows(value, blocks, blockIndex) + this.store.applyValue(newValue) + queueMicrotask(() => { + const newDivs = container.children + const target = newDivs[blockIndex - 1] as HTMLElement | undefined + if (target) { + target.focus() + const updatedBlocks = splitTokensIntoDragRows(this.store.state.tokens.get()) + const updatedBlock = updatedBlocks[blockIndex - 1] + if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos) + } + }) + return + } + // Previous row is a mark: navigate only + event.preventDefault() + queueMicrotask(() => { + const target = blockDivs[blockIndex - 1] as HTMLElement | undefined + if (target) { + target.focus() + Caret.setCaretToEnd(target) + } + }) + return + } + event.preventDefault() const joinPos = getMergeJoinPos(blocks, blockIndex) const newValue = mergeBlocks(value, blocks, blockIndex) @@ -185,6 +263,39 @@ export class KeyDownController { // Caret at end of non-last block: merge next block into current if (caretAtEnd && blockIndex < blocks.length - 1) { + if (isDragMode) { + const currBlock = blocks[blockIndex] + const nextBlock = blocks[blockIndex + 1] + const gap = nextBlock.startPos - currBlock.endPos + if (isTextRow(currBlock) && isTextRow(nextBlock) && gap === 2) { + event.preventDefault() + const joinPos = currBlock.endPos + const newValue = mergeDragRows(value, blocks, blockIndex + 1) + this.store.applyValue(newValue) + queueMicrotask(() => { + const newDivs = container.children + const target = newDivs[blockIndex] as HTMLElement | undefined + if (target) { + target.focus() + const updatedBlocks = splitTokensIntoDragRows(this.store.state.tokens.get()) + const updatedBlock = updatedBlocks[blockIndex] + if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos) + } + }) + return + } + // Next row is a mark: navigate only + event.preventDefault() + queueMicrotask(() => { + const target = blockDivs[blockIndex + 1] as HTMLElement | undefined + if (target) { + target.focus() + Caret.trySetIndex(target, 0) + } + }) + return + } + event.preventDefault() const joinPos = block.endPos const newValue = mergeBlocks(value, blocks, blockIndex + 1) @@ -205,7 +316,9 @@ export class KeyDownController { } #handleEnter(event: KeyboardEvent) { - if (!this.store.state.block.get()) return + const isBlockMode = !!this.store.state.block.get() + const isDragMode = !!this.store.state.drag.get() + if (!isBlockMode && !isDragMode) return if (event.key !== KEYBOARD.ENTER) return if (event.shiftKey) return @@ -229,20 +342,34 @@ export class KeyDownController { if (blockIndex === -1) return const tokens = this.store.state.tokens.get() - const blocks = splitTokensIntoBlocks(tokens) + const blocks = isDragMode ? splitTokensIntoDragRows(tokens) : splitTokensIntoBlocks(tokens) if (blockIndex >= blocks.length) return const block = blocks[blockIndex] const blockDiv = blockDivs[blockIndex] as HTMLElement const value = this.store.state.value.get() ?? this.store.state.previousValue.get() ?? '' - // Compute raw value offset at caret position using token positions - const absolutePos = getCaretRawPosInBlock(blockDiv, block) + if (!this.store.state.onChange.get()) return - // Insert BLOCK_SEPARATOR at the raw position - const newValue = value.slice(0, absolutePos) + BLOCK_SEPARATOR + value.slice(absolutePos) + if (isDragMode && !isTextRow(block)) { + // Mark row in drag mode: add a new empty text row after this row + const newValue = addDragRow(value, blocks, blockIndex) + this.store.applyValue(newValue) + queueMicrotask(() => { + const newBlockDivs = container.children + const newBlockIndex = blockIndex + 1 + if (newBlockIndex < newBlockDivs.length) { + const newBlock = newBlockDivs[newBlockIndex] as HTMLElement + newBlock.focus() + Caret.trySetIndex(newBlock, 0) + } + }) + return + } - if (!this.store.state.onChange.get()) return + // Text row (both block and drag modes): split at caret position + const absolutePos = getCaretRawPosInBlock(blockDiv, block) + const newValue = value.slice(0, absolutePos) + BLOCK_SEPARATOR + value.slice(absolutePos) this.store.applyValue(newValue) // Focus the new block after re-render @@ -258,7 +385,7 @@ export class KeyDownController { } #handleArrowUpDown(event: KeyboardEvent) { - if (!this.store.state.block.get()) return + if (!this.store.state.block.get() && !this.store.state.drag.get()) return const container = this.store.refs.container if (!container) return @@ -328,10 +455,10 @@ export function handleBeforeInput(store: Store, event: InputEvent): void { } if (selecting === 'all') store.state.selecting.set(undefined) - // In block mode the focus target is a block div, not a text span. + // In block/drag mode the focus target is a block div, not a text span. // Block-level keys (Enter, Backspace, Delete) are handled by KeyDownController. // Text insertions and in-block deletions need special handling to update state. - if (store.state.block.get()) { + if (store.state.block.get() || store.state.drag.get()) { handleBlockBeforeInput(store, event) return } @@ -472,7 +599,8 @@ function handleBlockBeforeInput(store: Store, event: InputEvent): void { const blockDiv = blockDivs[blockIndex] const tokens = store.state.tokens.get() - const blocks = splitTokensIntoBlocks(tokens) + const isDragMode = !!store.state.drag.get() + const blocks = isDragMode ? splitTokensIntoDragRows(tokens) : splitTokensIntoBlocks(tokens) if (blockIndex >= blocks.length) return const block = blocks[blockIndex] @@ -484,7 +612,9 @@ function handleBlockBeforeInput(store: Store, event: InputEvent): void { if (!target) return target.focus() // Use updated tokens (post-applyValue) for correct token positions - const updatedBlocks = splitTokensIntoBlocks(store.state.tokens.get()) + const updatedBlocks = isDragMode + ? splitTokensIntoDragRows(store.state.tokens.get()) + : splitTokensIntoBlocks(store.state.tokens.get()) const updatedBlock = updatedBlocks[blockIndex] if (updatedBlock) setCaretAtRawPos(target, updatedBlock, newRawPos) }) diff --git a/packages/common/core/src/features/store/Store.ts b/packages/common/core/src/features/store/Store.ts index 8207e995..ac45f966 100644 --- a/packages/common/core/src/features/store/Store.ts +++ b/packages/common/core/src/features/store/Store.ts @@ -73,6 +73,7 @@ export class Store { slots: undefined, slotProps: undefined, block: false, + drag: false, }, options.createUseHook ) diff --git a/packages/common/core/src/shared/types.ts b/packages/common/core/src/shared/types.ts index bdc7f09a..4bb1cc14 100644 --- a/packages/common/core/src/shared/types.ts +++ b/packages/common/core/src/shared/types.ts @@ -66,6 +66,7 @@ export interface MarkputState { slots: CoreSlots | undefined slotProps: CoreSlotProps | undefined block: boolean | {alwaysShowHandle: boolean} + drag: boolean | {alwaysShowHandle: boolean} } export type OverlayMatch = { diff --git a/packages/react/markput/src/components/BlockContainer.tsx b/packages/react/markput/src/components/BlockContainer.tsx index 848f4d4b..61aa34d1 100644 --- a/packages/react/markput/src/components/BlockContainer.tsx +++ b/packages/react/markput/src/components/BlockContainer.tsx @@ -3,11 +3,17 @@ import { resolveSlot, resolveSlotProps, splitTokensIntoBlocks, + splitTokensIntoDragRows, reorderBlocks, + reorderDragRows, addBlock, + addDragRow, deleteBlock, + deleteDragRow, duplicateBlock, + duplicateDragRow, getAlwaysShowHandle, + getAlwaysShowHandleDrag, type Block, } from '@markput/core' import type {CSSProperties, ElementType} from 'react' @@ -156,7 +162,9 @@ export const BlockContainer = memo(() => { const style = store.state.style.use() const readOnly = store.state.readOnly.use() const block = store.state.block.use() - const alwaysShowHandle = getAlwaysShowHandle(block) + const drag = store.state.drag.use() + const isDragMode = !!drag + const alwaysShowHandle = isDragMode ? getAlwaysShowHandleDrag(drag) : getAlwaysShowHandle(block) const value = store.state.value.use() const onChange = store.state.onChange.use() const key = store.key @@ -168,25 +176,31 @@ export const BlockContainer = memo(() => { const containerProps = useMemo(() => resolveSlotProps('container', slotProps), [slotProps]) const blocks = useMemo(() => { - const result = splitTokensIntoBlocks(tokens) + const result = isDragMode ? splitTokensIntoDragRows(tokens) : splitTokensIntoBlocks(tokens) return result.length > 0 ? result : [EMPTY_BLOCK] - }, [tokens]) + }, [tokens, isDragMode]) const blocksRef = useRef(blocks) blocksRef.current = blocks const handleReorder = useCallback( (sourceIndex: number, targetIndex: number) => { if (value == null || !onChange) return - const newValue = reorderBlocks(value, blocksRef.current, sourceIndex, targetIndex) + const newValue = isDragMode + ? reorderDragRows(value, blocksRef.current, sourceIndex, targetIndex) + : reorderBlocks(value, blocksRef.current, sourceIndex, targetIndex) if (newValue !== value) store.applyValue(newValue) }, - [store, value, onChange] + [store, value, onChange, isDragMode] ) const handleAdd = useCallback( (afterIndex: number) => { if (value == null || !onChange) return - store.applyValue(addBlock(value, blocksRef.current, afterIndex)) + store.applyValue( + isDragMode + ? addDragRow(value, blocksRef.current, afterIndex) + : addBlock(value, blocksRef.current, afterIndex) + ) queueMicrotask(() => { const container = store.refs.container if (!container) return @@ -195,23 +209,31 @@ export const BlockContainer = memo(() => { target?.focus() }) }, - [store, value, onChange] + [store, value, onChange, isDragMode] ) const handleDelete = useCallback( (index: number) => { if (value == null || !onChange) return - store.applyValue(deleteBlock(value, blocksRef.current, index)) + store.applyValue( + isDragMode + ? deleteDragRow(value, blocksRef.current, index) + : deleteBlock(value, blocksRef.current, index) + ) }, - [store, value, onChange] + [store, value, onChange, isDragMode] ) const handleDuplicate = useCallback( (index: number) => { if (value == null || !onChange) return - store.applyValue(duplicateBlock(value, blocksRef.current, index)) + store.applyValue( + isDragMode + ? duplicateDragRow(value, blocksRef.current, index) + : duplicateBlock(value, blocksRef.current, index) + ) }, - [store, value, onChange] + [store, value, onChange, isDragMode] ) const handleRequestMenu = useCallback((index: number, rect: DOMRect) => { diff --git a/packages/react/markput/src/components/MarkedInput.tsx b/packages/react/markput/src/components/MarkedInput.tsx index 0a4d7595..871a93cd 100644 --- a/packages/react/markput/src/components/MarkedInput.tsx +++ b/packages/react/markput/src/components/MarkedInput.tsx @@ -74,6 +74,11 @@ export interface MarkedInputProps( @@ -86,6 +91,7 @@ export function MarkedInput diff --git a/packages/react/storybook/src/pages/Drag/Drag.stories.tsx b/packages/react/storybook/src/pages/Drag/Drag.stories.tsx new file mode 100644 index 00000000..c269cc15 --- /dev/null +++ b/packages/react/storybook/src/pages/Drag/Drag.stories.tsx @@ -0,0 +1,215 @@ +import {MarkedInput} from '@markput/react' +import type {MarkProps, Option} from '@markput/react' +import type {Meta, StoryObj} from '@storybook/react-vite' +import type {CSSProperties} from 'react' +import {useState} from 'react' + +import {Text} from '../../shared/components/Text' + +export default { + title: 'MarkedInput/Drag', + tags: ['autodocs'], + component: MarkedInput, + parameters: { + docs: { + description: { + component: + 'Drag mode: each top-level token (mark or text fragment) is its own draggable row. Adjacent marks need no separator; adjacent text rows use \\n\\n.', + }, + }, + }, +} satisfies Meta + +type Story = StoryObj> + +// ─── Shared mark component ──────────────────────────────────────────────────── + +const Chip = ({value, style}: {value?: string; style?: CSSProperties}) => ( + + {value} + +) + +const mentionOptions: Option[] = [{markup: '@[__value__](__meta__)'}, {markup: '#[__value__]'}] + +// ─── Basic: mark rows are auto-delimited ────────────────────────────────────── + +export const BasicMentions: Story = { + render: () => { + const [value, setValue] = useState('@[Alice](alice)@[Bob](bob)@[Carol](carol)') + + return ( +
+

+ Three adjacent marks — no \n\n separator needed between them. Each mark is its own + draggable row. +

+ + +
+ ) + }, +} + +// ─── Mixed: text rows and mark rows ─────────────────────────────────────────── + +export const MixedTokens: Story = { + render: () => { + const [value, setValue] = useState('Introduction\n\n@[Alice](alice)@[Bob](bob)\n\nConclusion') + + return ( +
+

+ Text rows use \n\n as separator; mark rows are auto-delimited. Drag to reorder. +

+ + +
+ ) + }, +} + +// ─── Tag list: marks only ───────────────────────────────────────────────────── + +const TagChip = ({value}: {value?: string}) => ( + + #{value} + +) + +const tagOptions: Option[] = [{markup: '#[__value__]'}] + +export const TagList: Story = { + render: () => { + const [value, setValue] = useState('#[react]#[typescript]#[drag-and-drop]#[editor]') + + return ( +
+

+ Tag list where every row is a mark. No separators in the value string. +

+ + +
+ ) + }, +} + +// ─── Always-visible handles ─────────────────────────────────────────────────── + +export const AlwaysShowHandle: Story = { + render: () => { + const [value, setValue] = useState('@[Alice](alice)@[Bob](bob)@[Carol](carol)') + + return ( +
+ + +
+ ) + }, +} + +// ─── Nested marks inside a top-level mark ──────────────────────────────────── + +interface BoldMarkProps extends MarkProps { + style?: CSSProperties +} + +// For nested marks the outer mark receives `children` (rendered inner marks), +// not a plain `value` string — so render both. +const NestedChip = ({value, children, style}: BoldMarkProps) => ( + + {children ?? value} + +) + +// Use __nested__ so the parser supports marks inside the outer @[...](meta). +// Use the function form of `mark` so base props (including `children`) are preserved. +const boldOptions: Option[] = [ + {markup: '@[__nested__](__meta__)', mark: (props: MarkProps) => ({...props, style: {color: '#1a73e8'}})}, + {markup: '**__nested__**', mark: (props: MarkProps) => ({...props, style: {fontWeight: 700}})}, +] + +export const NestedMarks: Story = { + render: () => { + const [value, setValue] = useState('@[Hello **world**](demo)@[**Bold** mention](bold)\n\nPlain text row') + + return ( +
+

+ Nested marks stay inside their parent mark — they are NOT separate rows. +

+ + +
+ ) + }, +} \ No newline at end of file diff --git a/packages/vue/markput/src/components/BlockContainer.vue b/packages/vue/markput/src/components/BlockContainer.vue index a8457ce3..6935c60f 100644 --- a/packages/vue/markput/src/components/BlockContainer.vue +++ b/packages/vue/markput/src/components/BlockContainer.vue @@ -3,11 +3,17 @@ import { resolveSlot, resolveSlotProps, splitTokensIntoBlocks, + splitTokensIntoDragRows, reorderBlocks, + reorderDragRows, addBlock, + addDragRow, deleteBlock, + deleteDragRow, duplicateBlock, + duplicateDragRow, getAlwaysShowHandle, + getAlwaysShowHandleDrag, } from '@markput/core' import type {Component} from 'vue' import {computed} from 'vue' @@ -24,7 +30,11 @@ const className = store.state.className.use() const style = store.state.style.use() const readOnly = store.state.readOnly.use() const block = store.state.block.use() -const alwaysShowHandle = computed(() => getAlwaysShowHandle(block.value)) +const drag = store.state.drag.use() +const isDragMode = computed(() => !!drag.value) +const alwaysShowHandle = computed(() => + isDragMode.value ? getAlwaysShowHandleDrag(drag.value) : getAlwaysShowHandle(block.value) +) const value = store.state.value.use() const onChange = store.state.onChange.use() const key = store.key @@ -32,27 +42,43 @@ const key = store.key const containerTag = computed(() => resolveSlot('container', slots.value)) const containerProps = computed(() => resolveSlotProps('container', slotProps.value)) -const blocks = computed(() => splitTokensIntoBlocks(tokens.value)) +const blocks = computed(() => + isDragMode.value ? splitTokensIntoDragRows(tokens.value) : splitTokensIntoBlocks(tokens.value) +) function handleReorder(sourceIndex: number, targetIndex: number) { if (!value.value || !onChange.value) return - const newValue = reorderBlocks(value.value, blocks.value, sourceIndex, targetIndex) + const newValue = isDragMode.value + ? reorderDragRows(value.value, blocks.value, sourceIndex, targetIndex) + : reorderBlocks(value.value, blocks.value, sourceIndex, targetIndex) if (newValue !== value.value) store.applyValue(newValue) } function handleAdd(afterIndex: number) { if (!value.value || !onChange.value) return - store.applyValue(addBlock(value.value, blocks.value, afterIndex)) + store.applyValue( + isDragMode.value + ? addDragRow(value.value, blocks.value, afterIndex) + : addBlock(value.value, blocks.value, afterIndex) + ) } function handleDelete(index: number) { if (!value.value || !onChange.value) return - store.applyValue(deleteBlock(value.value, blocks.value, index)) + store.applyValue( + isDragMode.value + ? deleteDragRow(value.value, blocks.value, index) + : deleteBlock(value.value, blocks.value, index) + ) } function handleDuplicate(index: number) { if (!value.value || !onChange.value) return - store.applyValue(duplicateBlock(value.value, blocks.value, index)) + store.applyValue( + isDragMode.value + ? duplicateDragRow(value.value, blocks.value, index) + : duplicateBlock(value.value, blocks.value, index) + ) } diff --git a/packages/vue/markput/src/components/MarkedInput.vue b/packages/vue/markput/src/components/MarkedInput.vue index f000f6df..b73092ff 100644 --- a/packages/vue/markput/src/components/MarkedInput.vue +++ b/packages/vue/markput/src/components/MarkedInput.vue @@ -18,9 +18,10 @@ const props = withDefaults(defineProps(), { showOverlayOn: 'change', readOnly: false, block: false, + drag: false, }) -const ContainerImpl = computed(() => (props.block ? BlockContainer : Container)) +const ContainerImpl = computed(() => (props.block || props.drag ? BlockContainer : Container)) const emit = defineEmits<{ change: [value: string] @@ -43,6 +44,7 @@ function syncProps() { onChange: (v: string) => emit('change', v), readOnly: props.readOnly, block: props.block, + drag: props.drag, options: props.options, showOverlayOn: props.showOverlayOn, Mark: props.Mark, @@ -70,6 +72,7 @@ watch( props.slots, props.slotProps, props.block, + props.drag, ], syncProps ) diff --git a/packages/vue/markput/src/types.ts b/packages/vue/markput/src/types.ts index 6fcfccff..4bf984d5 100644 --- a/packages/vue/markput/src/types.ts +++ b/packages/vue/markput/src/types.ts @@ -36,6 +36,10 @@ export interface MarkedInputProps Date: Wed, 11 Mar 2026 16:53:57 +0300 Subject: [PATCH 2/8] feat(drag): implement drag-and-drop functionality for text and markdown blocks - Removed the Block stories for React and Vue as they are now integrated into the Drag stories. - Added new Drag stories for PlainText, Markdown, and ReadOnly modes, showcasing drag-and-drop capabilities. - Introduced block-level markdown options to support drag mode, ensuring proper handling of adjacent text and marks. - Created comprehensive tests for drag functionality, including row addition, deletion, and menu interactions. - Enhanced the MarkedInput component to support drag behavior, allowing for intuitive reordering of content. --- .../src/pages/Block/Block.stories.tsx | 306 ----------- .../Block.spec.tsx => Drag/Drag.spec.tsx} | 424 +++++++------- .../storybook/src/pages/Drag/Drag.stories.tsx | 268 ++++++++- .../src/pages/Nested/MarkdownOptions.ts | 9 +- .../src/pages/Block/Block.stories.ts | 184 ------- .../Block.spec.ts => Drag/Drag.spec.ts} | 372 ++++++------- .../storybook/src/pages/Drag/Drag.stories.ts | 516 ++++++++++++++++++ 7 files changed, 1151 insertions(+), 928 deletions(-) delete mode 100644 packages/react/storybook/src/pages/Block/Block.stories.tsx rename packages/react/storybook/src/pages/{Block/Block.spec.tsx => Drag/Drag.spec.tsx} (60%) delete mode 100644 packages/vue/storybook/src/pages/Block/Block.stories.ts rename packages/vue/storybook/src/pages/{Block/Block.spec.ts => Drag/Drag.spec.ts} (64%) create mode 100644 packages/vue/storybook/src/pages/Drag/Drag.stories.ts diff --git a/packages/react/storybook/src/pages/Block/Block.stories.tsx b/packages/react/storybook/src/pages/Block/Block.stories.tsx deleted file mode 100644 index eea6420a..00000000 --- a/packages/react/storybook/src/pages/Block/Block.stories.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import {MarkedInput} from '@markput/react' -import type {MarkProps, Option} from '@markput/react' -import type {Meta, StoryObj} from '@storybook/react-vite' -import type {CSSProperties, ReactNode} from 'react' -import {useState} from 'react' - -import {Text} from '../../shared/components/Text' -import {COMPLEX_MARKDOWN} from '../../shared/lib/sampleTexts' -import {markdownOptions} from '../Nested/MarkdownOptions' - -export default { - title: 'MarkedInput/Block', - tags: ['autodocs'], - component: MarkedInput, - parameters: { - docs: { - description: { - component: - 'Notion-like draggable blocks. Hover over a block to reveal the + and drag handle buttons. Drag to reorder, click + to add a block, click the grip to open a block menu (delete/duplicate).', - }, - }, - }, -} satisfies Meta - -type Story = StoryObj> - -const MarkdownMark = ({ - children, - value, - style, -}: { - value?: string - children?: ReactNode - style?: React.CSSProperties -}) => {children || value} - -export const BasicDraggable: Story = { - render: () => { - const [value, setValue] = useState(`# Welcome to Draggable Blocks - -This is the first paragraph. Hover to see the drag handle on the left. - -This is the second paragraph. Try dragging it above the first one! - -## Features - -- Drag handles appear on hover -- Drop indicators show where the block will land -- Blocks reorder by manipulating the underlying string`) - - return ( -
- - -
- ) - }, -} - -export const MarkdownDocument: Story = { - render: () => { - const [value, setValue] = useState(COMPLEX_MARKDOWN) - - return ( -
- - -
- ) - }, -} - -export const ReadOnlyDraggable: Story = { - render: () => { - const value = `# Read-Only Mode - -Drag handles are hidden in read-only mode. - -## Section A - -Content cannot be reordered. - -## Section B - -This is static content.` - - return ( -
- -
- ) - }, -} - -export const MobileBlocks: Story = { - render: () => { - const [value, setValue] = useState(`# Always-visible handles - -Drag handles are always visible — ideal for touch devices. - -## Try it - -Tap the grip icon to open the block menu.`) - - return ( -
- - -
- ) - }, -} - -export const PlainTextBlocks: Story = { - render: () => { - const [value, setValue] = useState(`First block of plain text - -Second block of plain text - -Third block of plain text - -Fourth block of plain text - -Fifth block of plain text`) - - return ( -
- - -
- ) - }, -} - -// --------------------------------------------------------------------------- -// Todo List — Notion-like checklist with nested hierarchy -// --------------------------------------------------------------------------- - -interface TodoMarkProps extends MarkProps { - style?: CSSProperties - todo?: 'pending' | 'done' -} - -const todoOptions: Option[] = [ - { - markup: '# __nested__\n\n', - mark: (props: MarkProps) => ({ - ...props, - style: {display: 'block', fontSize: '1.4em', fontWeight: 'bold', margin: '0.3em 0'} as CSSProperties, - }), - }, - { - markup: '- [ ] __nested__\n\n', - mark: (props: MarkProps) => ({...props, todo: 'pending' as const, style: {display: 'block'} as CSSProperties}), - }, - { - markup: '- [x] __nested__\n\n', - mark: (props: MarkProps) => ({ - ...props, - todo: 'done' as const, - style: {display: 'block', textDecoration: 'line-through', opacity: 0.5} as CSSProperties, - }), - }, - { - markup: '\t- [ ] __nested__\n\n', - mark: (props: MarkProps) => ({ - ...props, - todo: 'pending' as const, - style: {display: 'block', paddingLeft: '1.5em'} as CSSProperties, - }), - }, - { - markup: '\t- [x] __nested__\n\n', - mark: (props: MarkProps) => ({ - ...props, - todo: 'done' as const, - style: { - display: 'block', - paddingLeft: '1.5em', - textDecoration: 'line-through', - opacity: 0.5, - } as CSSProperties, - }), - }, - { - markup: '\t\t- [ ] __nested__\n\n', - mark: (props: MarkProps) => ({ - ...props, - todo: 'pending' as const, - style: {display: 'block', paddingLeft: '3em'} as CSSProperties, - }), - }, - { - markup: '\t\t- [x] __nested__\n\n', - mark: (props: MarkProps) => ({ - ...props, - todo: 'done' as const, - style: { - display: 'block', - paddingLeft: '3em', - textDecoration: 'line-through', - opacity: 0.5, - } as CSSProperties, - }), - }, - { - markup: '> __nested__\n\n', - mark: (props: MarkProps) => ({ - ...props, - style: {display: 'block', fontSize: '0.85em', color: '#888', fontStyle: 'italic'} as CSSProperties, - }), - }, -] - -const TodoMark = ({children, value, style, todo}: TodoMarkProps) => ( - - {todo && {todo === 'done' ? '\u2611' : '\u2610'}} - {children || value} - -) - -const TODO_VALUE = `# \u{1F4CB} Project Launch Checklist - -- [ ] Design Phase - -\t- [ ] Create wireframes - -\t- [x] Define color palette - -\t- [ ] Design component library - -- [x] Research - -\t- [x] Analyze competitors - -\t- [x] User interviews - -\t\t- [x] Draft interview questions - -\t\t- [x] Schedule 5 sessions - -- [ ] Development - -\t- [ ] Set up CI/CD pipeline - -\t- [x] Write unit tests - -\t- [ ] API integration - -\t\t- [ ] Auth endpoints - -\t\t- [ ] Data sync - -- [ ] Launch - -\t- [ ] Final QA pass - -\t- [ ] Deploy to production - -> \u2610 = pending \u2611 = done` - -export const TodoList: Story = { - render: () => { - const [value, setValue] = useState(TODO_VALUE) - - return ( -
- - -
- ) - }, -} \ No newline at end of file diff --git a/packages/react/storybook/src/pages/Block/Block.spec.tsx b/packages/react/storybook/src/pages/Drag/Drag.spec.tsx similarity index 60% rename from packages/react/storybook/src/pages/Block/Block.spec.tsx rename to packages/react/storybook/src/pages/Drag/Drag.spec.tsx index 1f1f6277..6bd545c2 100644 --- a/packages/react/storybook/src/pages/Block/Block.spec.tsx +++ b/packages/react/storybook/src/pages/Drag/Drag.spec.tsx @@ -4,9 +4,9 @@ import {render} from 'vitest-browser-react' import {page, userEvent} from 'vitest/browser' import {focusAtEnd, focusAtStart} from '../../shared/lib/focus' -import * as BlockStories from './Block.stories' +import * as DragStories from './Drag.stories' -const {BasicDraggable, MarkdownDocument, PlainTextBlocks, ReadOnlyDraggable} = composeStories(BlockStories) +const {PlainTextDrag, MarkdownDrag, ReadOnlyDrag} = composeStories(DragStories) const GRIP_SELECTOR = 'button[aria-label="Drag to reorder or click for options"]' @@ -33,8 +33,7 @@ function getRawValue(container: Element) { /** * Simulate an HTML5 drag-and-drop: drag the grip at sourceGripIndex and drop it - * onto the block at targetBlockIndex. The drop lands in the 'after' position - * (cursor past the midpoint of the target) by default. + * onto the block at targetBlockIndex. */ async function simulateDragBlock( container: Element, @@ -49,10 +48,8 @@ async function simulateDragBlock( const dt = new DataTransfer() - // dragstart — React handler sets dt data as side-effect grip.dispatchEvent(new DragEvent('dragstart', {bubbles: true, cancelable: true, dataTransfer: dt})) - // dragover — React handler reads clientY to set dropPosition state const rect = targetBlock.getBoundingClientRect() targetBlock.dispatchEvent( new DragEvent('dragover', { @@ -63,14 +60,11 @@ async function simulateDragBlock( }) ) - // Allow React to flush the dropPosition state update before drop fires await new Promise(r => setTimeout(r, 50)) - // drop — React handler reads dt data and calls onReorder targetBlock.dispatchEvent(new DragEvent('drop', {bubbles: true, cancelable: true, dataTransfer: dt})) grip.dispatchEvent(new DragEvent('dragend', {bubbles: true, cancelable: true})) - // Allow React to re-render after reorder await new Promise(r => setTimeout(r, 50)) } @@ -81,29 +75,24 @@ async function openMenuForGrip(container: Element, gripIndex: number) { await userEvent.click(grip) } -describe('Feature: blocks', () => { - it('should render 5 blocks for BasicDraggable', async () => { - const {container} = await render() +describe('Feature: drag rows', () => { + it('should render 5 rows for PlainTextDrag', async () => { + const {container} = await render() expect(getGrips(container)).toHaveLength(5) }) - it('should render 6 blocks for MarkdownDocument', async () => { - const {container} = await render() - expect(getGrips(container)).toHaveLength(6) - }) - - it('should render 5 blocks for PlainTextBlocks', async () => { - const {container} = await render() + it('should render 5 rows for MarkdownDrag', async () => { + const {container} = await render() expect(getGrips(container)).toHaveLength(5) }) it('should render no grip buttons in read-only mode', async () => { - const {container} = await render() + const {container} = await render() expect(getGrips(container)).toHaveLength(0) }) it('should render content in read-only mode', async () => { - await render() + await render() await expect.element(page.getByText(/Read-Only/).first()).toBeInTheDocument() await expect.element(page.getByText(/Section A/).first()).toBeInTheDocument() await expect.element(page.getByText(/Section B/).first()).toBeInTheDocument() @@ -111,7 +100,7 @@ describe('Feature: blocks', () => { describe('menu', () => { it('should open with Add below, Duplicate, Delete options', async () => { - const {container} = await render() + const {container} = await render() await openMenuForGrip(container, 0) await expect.element(page.getByText('Add below')).toBeInTheDocument() @@ -120,7 +109,7 @@ describe('Feature: blocks', () => { }) it('should close on Escape', async () => { - const {container} = await render() + const {container} = await render() await openMenuForGrip(container, 0) await expect.element(page.getByText('Add below')).toBeInTheDocument() @@ -129,7 +118,7 @@ describe('Feature: blocks', () => { }) it('should close when clicking outside', async () => { - const {container} = await render() + const {container} = await render() await openMenuForGrip(container, 0) await expect.element(page.getByText('Add below')).toBeInTheDocument() @@ -138,33 +127,33 @@ describe('Feature: blocks', () => { }) }) - describe('add block', () => { - it('should increase block count by 1 when adding below first block', async () => { - const {container} = await render() + describe('add row', () => { + it('should increase row count by 1 when adding below first row', async () => { + const {container} = await render() await openMenuForGrip(container, 0) await userEvent.click(page.getByText('Add below').element()) expect(getGrips(container)).toHaveLength(6) }) - it('should increase block count by 1 when adding below middle block', async () => { - const {container} = await render() + it('should increase row count by 1 when adding below middle row', async () => { + const {container} = await render() await openMenuForGrip(container, 2) await userEvent.click(page.getByText('Add below').element()) expect(getGrips(container)).toHaveLength(6) }) - it('should increase block count by 1 when adding below last block', async () => { - const {container} = await render() + it('should increase row count by 1 when adding below last row', async () => { + const {container} = await render() await openMenuForGrip(container, 4) await userEvent.click(page.getByText('Add below').element()) expect(getGrips(container)).toHaveLength(6) }) - it('should insert an empty block between the target and next block', async () => { - const {container} = await render() + it('should insert an empty row between the target and next row', async () => { + const {container} = await render() await openMenuForGrip(container, 0) await userEvent.click(page.getByText('Add below').element()) @@ -172,8 +161,8 @@ describe('Feature: blocks', () => { expect(raw).toContain('First block of plain text\n\n\n\nSecond block of plain text') }) - it('should not create a trailing separator when adding below last block', async () => { - const {container} = await render() + it('should not create a trailing separator when adding below last row', async () => { + const {container} = await render() await openMenuForGrip(container, 4) await userEvent.click(page.getByText('Add below').element()) @@ -182,9 +171,9 @@ describe('Feature: blocks', () => { }) it('should work when value is empty', async () => { - const {container} = await render() + const {container} = await render() - // Delete all blocks until value is '' — sequential DOM interactions + // Delete all rows until value is '' // eslint-disable-next-line no-await-in-loop for (let i = 4; i > 0; i--) { await openMenuForGrip(container, i) @@ -193,7 +182,7 @@ describe('Feature: blocks', () => { await openMenuForGrip(container, 0) await userEvent.click(page.getByText('Delete').element()) - // Editor renders 1 empty block even when value is '' + // Editor renders 1 empty row even when value is '' expect(getGrips(container)).toHaveLength(1) await openMenuForGrip(container, 0) @@ -203,17 +192,17 @@ describe('Feature: blocks', () => { }) }) - describe('delete block', () => { - it('should decrease count by 1 when deleting middle block', async () => { - const {container} = await render() + describe('delete row', () => { + it('should decrease count by 1 when deleting middle row', async () => { + const {container} = await render() await openMenuForGrip(container, 2) await userEvent.click(page.getByText('Delete').element()) expect(getGrips(container)).toHaveLength(4) }) - it('should preserve remaining content when deleting first block', async () => { - const {container} = await render() + it('should preserve remaining content when deleting first row', async () => { + const {container} = await render() await openMenuForGrip(container, 0) await userEvent.click(page.getByText('Delete').element()) @@ -221,8 +210,8 @@ describe('Feature: blocks', () => { expect(getRawValue(container)).toContain('Second block of plain text') }) - it('should decrease count by 1 when deleting last block', async () => { - const {container} = await render() + it('should decrease count by 1 when deleting last row', async () => { + const {container} = await render() await openMenuForGrip(container, 4) await userEvent.click(page.getByText('Delete').element()) @@ -231,10 +220,9 @@ describe('Feature: blocks', () => { expect(getRawValue(container)).not.toContain('Fifth block of plain text') }) - it('should result in empty value when deleting the last remaining block', async () => { - const {container} = await render() + it('should result in empty value when deleting the last remaining row', async () => { + const {container} = await render() - // Sequential DOM interactions — must await each step // eslint-disable-next-line no-await-in-loop for (let i = 4; i > 0; i--) { await openMenuForGrip(container, i) @@ -250,9 +238,9 @@ describe('Feature: blocks', () => { }) }) - describe('duplicate block', () => { - it('should increase count by 1 when duplicating first block', async () => { - const {container} = await render() + describe('duplicate row', () => { + it('should increase count by 1 when duplicating first row', async () => { + const {container} = await render() await openMenuForGrip(container, 0) await userEvent.click(page.getByText('Duplicate').element()) @@ -260,7 +248,7 @@ describe('Feature: blocks', () => { }) it('should create a copy with the same text content', async () => { - const {container} = await render() + const {container} = await render() await openMenuForGrip(container, 0) await userEvent.click(page.getByText('Duplicate').element()) @@ -268,8 +256,8 @@ describe('Feature: blocks', () => { expect(matches).toHaveLength(2) }) - it('should increase count by 1 when duplicating last block', async () => { - const {container} = await render() + it('should increase count by 1 when duplicating last row', async () => { + const {container} = await render() await openMenuForGrip(container, 4) await userEvent.click(page.getByText('Duplicate').element()) @@ -278,8 +266,8 @@ describe('Feature: blocks', () => { }) describe('enter key', () => { - it('should create a new block when pressing Enter at end of block', async () => { - const {container} = await render() + it('should create a new row when pressing Enter at end of text row', async () => { + const {container} = await render() expect(getGrips(container)).toHaveLength(5) const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0])) @@ -289,8 +277,8 @@ describe('Feature: blocks', () => { expect(getGrips(container)).toHaveLength(6) }) - it('should preserve all block content after pressing Enter', async () => { - const {container} = await render() + it('should preserve all row content after pressing Enter', async () => { + const {container} = await render() const originalValue = getRawValue(container) const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0])) @@ -303,8 +291,8 @@ describe('Feature: blocks', () => { expect(newValue).toContain('Fifth block of plain text') }) - it('should not create a new block when pressing Shift+Enter', async () => { - const {container} = await render() + it('should not create a new row when pressing Shift+Enter', async () => { + const {container} = await render() const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0])) await focusAtEnd(editable) @@ -312,11 +300,22 @@ describe('Feature: blocks', () => { expect(getGrips(container)).toHaveLength(5) }) + + it('should create a new empty row after a mark row when pressing Enter', async () => { + const {container} = await render() + const before = getBlocks(container).length + // block[0] is the h1 mark row + const markBlock = getBlocks(container)[0] + markBlock.focus() + await userEvent.keyboard('{Enter}') + + expect(getGrips(container)).toHaveLength(before + 1) + }) }) describe('drag & drop', () => { - it('should reorder blocks when dragging block 0 after block 2', async () => { - const {container} = await render() + it('should reorder rows when dragging row 0 after row 2', async () => { + const {container} = await render() await simulateDragBlock(container, 0, 2) @@ -324,8 +323,8 @@ describe('Feature: blocks', () => { expect(raw.indexOf('First block of plain text')).toBeGreaterThan(raw.indexOf('Third block of plain text')) }) - it('should not change order when dragging block onto itself', async () => { - const {container} = await render() + it('should not change order when dragging row onto itself', async () => { + const {container} = await render() const original = getRawValue(container) await simulateDragBlock(container, 1, 1) @@ -334,16 +333,16 @@ describe('Feature: blocks', () => { }) }) - describe('backspace on empty block', () => { - it('should delete the block and reduce count by 1', async () => { - const {container} = await render() + describe('backspace on empty row', () => { + it('should delete the row and reduce count by 1', async () => { + const {container} = await render() - // Insert an empty block after block 0 + // Insert an empty row after row 0 await openMenuForGrip(container, 0) await userEvent.click(page.getByText('Add below').element()) expect(getGrips(container)).toHaveLength(6) - // Focus the new empty block (index 1) and press Backspace + // Focus the new empty row (index 1) and press Backspace const newBlockDiv = getBlockDiv(getGrips(container)[1]) newBlockDiv.focus() await userEvent.keyboard('{Backspace}') @@ -351,19 +350,19 @@ describe('Feature: blocks', () => { expect(getGrips(container)).toHaveLength(5) }) - it('should not delete a non-empty block on Backspace', async () => { - const {container} = await render() + it('should not delete a non-empty row on Backspace', async () => { + const {container} = await render() const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0])) await focusAtEnd(editable) await userEvent.keyboard('{Backspace}') - // Only one character was deleted, not the whole block + // Only one character was deleted, not the whole row expect(getGrips(container)).toHaveLength(5) }) }) - it('should focus the new empty block after Add below', async () => { - const {container} = await render() + it('should focus the new empty row after Add below', async () => { + const {container} = await render() await openMenuForGrip(container, 0) await userEvent.click(page.getByText('Add below').element()) @@ -371,19 +370,18 @@ describe('Feature: blocks', () => { expect(document.activeElement).toBe(newBlockDiv) }) - it('should split block at caret when pressing Enter at the beginning', async () => { - const {container} = await render() + it('should split row at caret when pressing Enter at the beginning', async () => { + const {container} = await render() const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0])) await focusAtStart(editable) await userEvent.keyboard('{Enter}') expect(getGrips(container)).toHaveLength(6) - // Original first-block text should still be present expect(getRawValue(container)).toContain('First block of plain text') }) it('should restore original value after add then delete', async () => { - const {container} = await render() + const {container} = await render() const original = getRawValue(container) await openMenuForGrip(container, 0) @@ -398,7 +396,7 @@ describe('Feature: blocks', () => { }) it('should restore original value after duplicate then delete', async () => { - const {container} = await render() + const {container} = await render() const original = getRawValue(container) await openMenuForGrip(container, 0) @@ -459,10 +457,10 @@ function dispatchInsertText(target: HTMLElement, text: string) { ) } -describe('Feature: block keyboard navigation', () => { - describe('ArrowLeft cross-block', () => { - it('should move focus to previous block when at start of block', async () => { - const {container} = await render() +describe('Feature: drag row keyboard navigation', () => { + describe('ArrowLeft cross-row', () => { + it('should move focus to previous row when at start of row', async () => { + const {container} = await render() const blocks = getBlocks(container) await focusAtStart(getEditableInBlock(blocks[1])) @@ -471,19 +469,18 @@ describe('Feature: block keyboard navigation', () => { expect(document.activeElement).toBe(blocks[0]) }) - it('should not cross to previous block when caret is mid-block', async () => { - const {container} = await render() + it('should not cross to previous row when caret is mid-row', async () => { + const {container} = await render() const blocks = getBlocks(container) await focusAtEnd(getEditableInBlock(blocks[1])) await userEvent.keyboard('{ArrowLeft}') - // Still in block 1 expect(document.activeElement).toBe(blocks[1]) }) - it('should not cross block boundary from the first block', async () => { - const {container} = await render() + it('should not cross row boundary from the first row', async () => { + const {container} = await render() const blocks = getBlocks(container) await focusAtStart(getEditableInBlock(blocks[0])) @@ -493,9 +490,9 @@ describe('Feature: block keyboard navigation', () => { }) }) - describe('ArrowRight cross-block', () => { - it('should move focus to next block when at end of block', async () => { - const {container} = await render() + describe('ArrowRight cross-row', () => { + it('should move focus to next row when at end of row', async () => { + const {container} = await render() const blocks = getBlocks(container) await focusAtEnd(getEditableInBlock(blocks[0])) @@ -504,19 +501,18 @@ describe('Feature: block keyboard navigation', () => { expect(document.activeElement).toBe(blocks[1]) }) - it('should not cross to next block when caret is mid-block', async () => { - const {container} = await render() + it('should not cross to next row when caret is mid-row', async () => { + const {container} = await render() const blocks = getBlocks(container) await focusAtStart(getEditableInBlock(blocks[0])) await userEvent.keyboard('{ArrowRight}') - // Still in block 0 expect(document.activeElement).toBe(blocks[0]) }) - it('should not cross block boundary from the last block', async () => { - const {container} = await render() + it('should not cross row boundary from the last row', async () => { + const {container} = await render() const blocks = getBlocks(container) const last = blocks[blocks.length - 1] @@ -527,9 +523,9 @@ describe('Feature: block keyboard navigation', () => { }) }) - describe('ArrowDown cross-block', () => { - it('should move focus to next block when on last line of block', async () => { - const {container} = await render() + describe('ArrowDown cross-row', () => { + it('should move focus to next row when on last line of row', async () => { + const {container} = await render() const blocks = getBlocks(container) await focusAtEnd(getEditableInBlock(blocks[0])) @@ -538,8 +534,8 @@ describe('Feature: block keyboard navigation', () => { expect(document.activeElement).toBe(blocks[1]) }) - it('should not cross block boundary from the last block', async () => { - const {container} = await render() + it('should not cross row boundary from the last row', async () => { + const {container} = await render() const blocks = getBlocks(container) const last = blocks[blocks.length - 1] @@ -550,9 +546,9 @@ describe('Feature: block keyboard navigation', () => { }) }) - describe('ArrowUp cross-block', () => { - it('should move focus to previous block when on first line of block', async () => { - const {container} = await render() + describe('ArrowUp cross-row', () => { + it('should move focus to previous row when on first line of row', async () => { + const {container} = await render() const blocks = getBlocks(container) await focusAtStart(getEditableInBlock(blocks[1])) @@ -561,8 +557,8 @@ describe('Feature: block keyboard navigation', () => { expect(document.activeElement).toBe(blocks[0]) }) - it('should not cross block boundary from the first block', async () => { - const {container} = await render() + it('should not cross row boundary from the first row', async () => { + const {container} = await render() const blocks = getBlocks(container) await focusAtStart(getEditableInBlock(blocks[0])) @@ -572,9 +568,9 @@ describe('Feature: block keyboard navigation', () => { }) }) - describe('Backspace merge blocks', () => { - it('should merge with previous block when Backspace pressed at start of non-empty block', async () => { - const {container} = await render() + describe('Backspace merge rows (text+text)', () => { + it('should merge with previous text row when Backspace pressed at start of non-empty row', async () => { + const {container} = await render() const before = getBlocks(container).length await focusAtStart(getEditableInBlock(getBlocks(container)[1])) @@ -583,8 +579,8 @@ describe('Feature: block keyboard navigation', () => { expect(getBlocks(container)).toHaveLength(before - 1) }) - it('should preserve content of both merged blocks', async () => { - const {container} = await render() + it('should preserve content of both merged rows', async () => { + const {container} = await render() await focusAtStart(getEditableInBlock(getBlocks(container)[1])) await userEvent.keyboard('{Backspace}') @@ -594,8 +590,8 @@ describe('Feature: block keyboard navigation', () => { expect(raw).toContain('Second block of plain text') }) - it('should keep focus in the previous block after merge', async () => { - const {container} = await render() + it('should keep focus in the previous row after merge', async () => { + const {container} = await render() const blocks = getBlocks(container) const prevBlock = blocks[0] @@ -605,58 +601,46 @@ describe('Feature: block keyboard navigation', () => { expect(document.activeElement).toBe(prevBlock) }) - it('should only delete one block at a time on Backspace', async () => { - const {container} = await render() + it('should only delete one row at a time on Backspace', async () => { + const {container} = await render() expect(getBlocks(container)).toHaveLength(5) await focusAtStart(getEditableInBlock(getBlocks(container)[1])) await userEvent.keyboard('{Backspace}') - // Must be exactly 4 — not 3 (double-delete regression guard) expect(getBlocks(container)).toHaveLength(4) }) - describe('Backspace into a mark block (heading with embedded \\n\\n separator)', () => { - // Bug: blocks whose mark token includes the \n\n separator have endPos === next block's startPos. - // mergeBlocks must detect this and strip the separator from inside the mark. + describe('Backspace at start of text row after a mark row (navigate-only in drag mode)', () => { + // In drag mode, mark→text boundary is navigate-only: Backspace moves focus + // to the mark row but does NOT merge (can't combine text into a mark token). - it('should reduce block count when Backspace at start of block after heading mark', async () => { - const {container} = await render() + it('should NOT reduce row count when Backspace at start of text row after mark row', async () => { + const {container} = await render() const before = getBlocks(container).length - // block[1] is "This is a powerful..." which follows the heading mark (block[0]) - await focusAtStart(getEditableInBlock(getBlocks(container)[1])) - await userEvent.keyboard('{Backspace}') - - expect(getBlocks(container)).toHaveLength(before - 1) - }) - - it('should preserve content of both blocks after merging into heading mark', async () => { - const {container} = await render() - + // block[1] is the first text row following the h1 mark row await focusAtStart(getEditableInBlock(getBlocks(container)[1])) await userEvent.keyboard('{Backspace}') - const raw = getRawValue(container) - expect(raw).toContain('Marked Input') - expect(raw).toContain('powerful') + expect(getBlocks(container)).toHaveLength(before) }) - it('should keep focus in the heading block after Backspace merge', async () => { - const {container} = await render() - const headingBlock = getBlocks(container)[0] + it('should move focus to the mark row on Backspace at mark boundary', async () => { + const {container} = await render() + const markBlock = getBlocks(container)[0] await focusAtStart(getEditableInBlock(getBlocks(container)[1])) await userEvent.keyboard('{Backspace}') - expect(document.activeElement).toBe(headingBlock) + expect(document.activeElement).toBe(markBlock) }) }) }) - describe('Delete merge blocks', () => { - it('should merge with next block when Delete pressed at end of non-last block', async () => { - const {container} = await render() + describe('Delete merge rows (text+text)', () => { + it('should merge with next text row when Delete pressed at end of non-last row', async () => { + const {container} = await render() const before = getBlocks(container).length await focusAtEnd(getEditableInBlock(getBlocks(container)[0])) @@ -665,8 +649,8 @@ describe('Feature: block keyboard navigation', () => { expect(getBlocks(container)).toHaveLength(before - 1) }) - it('should preserve content of both merged blocks', async () => { - const {container} = await render() + it('should preserve content of both merged rows', async () => { + const {container} = await render() await focusAtEnd(getEditableInBlock(getBlocks(container)[0])) await userEvent.keyboard('{Delete}') @@ -676,8 +660,8 @@ describe('Feature: block keyboard navigation', () => { expect(raw).toContain('Second block of plain text') }) - it('should keep focus in the current block after Delete merge', async () => { - const {container} = await render() + it('should keep focus in the current row after Delete merge', async () => { + const {container} = await render() const currentBlock = getBlocks(container)[0] await focusAtEnd(getEditableInBlock(currentBlock)) @@ -686,8 +670,8 @@ describe('Feature: block keyboard navigation', () => { expect(document.activeElement).toBe(currentBlock) }) - it('should not merge when Delete pressed at end of last block', async () => { - const {container} = await render() + it('should not merge when Delete pressed at end of last row', async () => { + const {container} = await render() const blocks = getBlocks(container) const last = blocks[blocks.length - 1] @@ -697,9 +681,9 @@ describe('Feature: block keyboard navigation', () => { expect(getBlocks(container)).toHaveLength(5) }) - describe('Delete at start of block', () => { - it('should merge with previous block when Delete pressed at start of non-first block', async () => { - const {container} = await render() + describe('Delete at start of row', () => { + it('should merge with previous row when Delete pressed at start of non-first row', async () => { + const {container} = await render() const before = getBlocks(container).length await focusAtStart(getEditableInBlock(getBlocks(container)[1])) @@ -708,8 +692,8 @@ describe('Feature: block keyboard navigation', () => { expect(getBlocks(container)).toHaveLength(before - 1) }) - it('should preserve content of both merged blocks', async () => { - const {container} = await render() + it('should preserve content of both merged rows', async () => { + const {container} = await render() await focusAtStart(getEditableInBlock(getBlocks(container)[1])) await userEvent.keyboard('{Delete}') @@ -719,8 +703,8 @@ describe('Feature: block keyboard navigation', () => { expect(raw).toContain('Second block of plain text') }) - it('should keep focus in the previous block after Delete merge', async () => { - const {container} = await render() + it('should keep focus in the previous row after Delete merge', async () => { + const {container} = await render() const prevBlock = getBlocks(container)[0] await focusAtStart(getEditableInBlock(getBlocks(container)[1])) @@ -729,8 +713,8 @@ describe('Feature: block keyboard navigation', () => { expect(document.activeElement).toBe(prevBlock) }) - it('should not merge when Delete pressed at start of first block', async () => { - const {container} = await render() + it('should not merge when Delete pressed at start of first row', async () => { + const {container} = await render() const before = getBlocks(container).length await focusAtStart(getEditableInBlock(getBlocks(container)[0])) @@ -740,117 +724,93 @@ describe('Feature: block keyboard navigation', () => { }) }) - describe('Delete into a mark block (heading with embedded \\n\\n separator)', () => { - // Bug: blocks whose mark token includes the \n\n separator have endPos === next block's startPos. - // mergeBlocks must detect this and strip the separator from inside the mark. + describe('Delete at mark→text boundary (navigate-only in drag mode)', () => { + // In drag mode, Backspace/Delete at a mark boundary navigates, does not merge. - it('should reduce block count when Delete at start of block after heading mark', async () => { - const {container} = await render() + it('should NOT reduce row count when Delete at start of text row after mark row', async () => { + const {container} = await render() const before = getBlocks(container).length - // block[1] is "This is a powerful..." which follows the heading mark (block[0]) await focusAtStart(getEditableInBlock(getBlocks(container)[1])) await userEvent.keyboard('{Delete}') - expect(getBlocks(container)).toHaveLength(before - 1) - }) - - it('should preserve content of both blocks after merging into heading mark', async () => { - const {container} = await render() - - await focusAtStart(getEditableInBlock(getBlocks(container)[1])) - await userEvent.keyboard('{Delete}') - - const raw = getRawValue(container) - expect(raw).toContain('Marked Input') - expect(raw).toContain('powerful') + expect(getBlocks(container)).toHaveLength(before) }) - it('should keep focus in the heading block after Delete merge', async () => { - const {container} = await render() - const headingBlock = getBlocks(container)[0] + it('should move focus to mark row on Delete at mark boundary', async () => { + const {container} = await render() + const markBlock = getBlocks(container)[0] await focusAtStart(getEditableInBlock(getBlocks(container)[1])) await userEvent.keyboard('{Delete}') - expect(document.activeElement).toBe(headingBlock) + expect(document.activeElement).toBe(markBlock) }) }) }) - describe('typing in blocks (BUG3)', () => { - it('should update raw value when typing a character at end of block', async () => { - const {container} = await render() + describe('typing in rows', () => { + it('should update raw value when typing a character at end of row', async () => { + const {container} = await render() await focusAtEnd(getEditableInBlock(getBlocks(container)[0])) await userEvent.keyboard('!') expect(getRawValue(container)).toContain('First block of plain text!') }) - it('should update raw value when deleting a character with Backspace mid-block', async () => { - const {container} = await render() + it('should update raw value when deleting a character with Backspace mid-row', async () => { + const {container} = await render() await focusAtEnd(getEditableInBlock(getBlocks(container)[0])) await userEvent.keyboard('{Backspace}') - // "First block of plain text" → backspace → "First block of plain tex" expect(getRawValue(container)).toContain('First block of plain tex') expect(getRawValue(container)).not.toContain('First block of plain text\n\n') }) - it('should not wipe all blocks when Ctrl+A in focused block then typing (BUG1)', async () => { - const {container} = await render() + it('should not wipe all rows when Ctrl+A in focused row then typing', async () => { + const {container} = await render() const blocks = getBlocks(container) - // Focus block 1 and Ctrl+A — bug sets selecting='all' and replaces all content on next keystroke getEditableInBlock(blocks[1]).focus() await userEvent.keyboard('{Control>}a{/Control}') await userEvent.keyboard('X') - // With bug: raw value becomes 'X' (all wiped) and first block content gone - // After fix: first block unchanged, only block 1 affected expect(getRawValue(container)).not.toBe('X') expect(getRawValue(container)).toContain('First block of plain text') }) - it('should append character after last mark when typing at end of mark block (BUG-CARET-MARK)', async () => { - const {container} = await render() + it('should append character after last mark when typing at end of mark row', async () => { + const {container} = await render() const blocks = getBlocks(container) - // block[0] raw = '# Welcome to **Marked Input**' + // block[0] raw = '# Welcome to Draggable Blocks\n\n' await focusAtEnd(blocks[0]) - // Use synthetic event to avoid browser selection drift at mark boundaries dispatchInsertText(blocks[0], '!') await new Promise(r => setTimeout(r, 50)) - // With the bug: raw/visual offset mismatch puts '!' mid-mark, e.g. '# Welcome to **Marked Inpu!t**' - // After fix: '!' appended after the last mark at correct position const block0Raw = getRawValue(container).split('\n\n')[0] - expect(block0Raw).toBe('# Welcome to **Marked Input**!') + expect(block0Raw).toBe('# Welcome to Draggable Blocks!') }) - it('should insert character at correct position mid-text within a mark block (BUG-CARET-MARK)', async () => { - const {container} = await render() + it('should insert character at correct position mid-text within a mark row', async () => { + const {container} = await render() const blocks = getBlocks(container) - // block[0] raw = '# Welcome to **Marked Input**' - // block[0] h1 renders nested children (no '# ' prefix visible): - // visual text = 'Welcome to Marked Input' (23 chars) - // focusAtStart → cursor before 'W' (raw pos 2, after h1 prefix '# ') - // ArrowRight x2 → cursor 2 visual positions right → before 'l' (raw pos 4) + // block[0] raw = '# Welcome to Draggable Blocks\n\n' + // h1 renders nested children: 'Welcome to Draggable Blocks' (no '# ' prefix visible) + // focusAtStart → cursor before 'W' (raw pos 2, after '# ') + // ArrowRight x2 → before 'l' (raw pos 4) await focusAtStart(blocks[0]) await userEvent.keyboard('{ArrowRight}{ArrowRight}') - // Use synthetic event to capture selection before browser can drift dispatchInsertText(blocks[0], 'X') await new Promise(r => setTimeout(r, 50)) - // With the bug: getDomRawPos returned token.position.end = 31 for any mark cursor, - // inserting X at start of block 1. After fix: X at raw pos 4 (within nested TextToken). const block0Raw = getRawValue(container).split('\n\n')[0] - expect(block0Raw).toBe('# WeXlcome to **Marked Input**') + expect(block0Raw).toBe('# WeXlcome to Draggable Blocks') }) }) - describe('paste in blocks (BUG-PASTE)', () => { - it('should update raw value when pasting text at end of a plain text block', async () => { - const {container} = await render() + describe('paste in rows', () => { + it('should update raw value when pasting text at end of a plain text row', async () => { + const {container} = await render() const blocks = getBlocks(container) await focusAtEnd(getEditableInBlock(blocks[0])) dispatchPaste(blocks[0], ' pasted') @@ -859,8 +819,8 @@ describe('Feature: block keyboard navigation', () => { expect(getRawValue(container)).toContain('First block of plain text pasted') }) - it('should not affect other blocks when pasting in one block', async () => { - const {container} = await render() + it('should not affect other rows when pasting in one row', async () => { + const {container} = await render() const blocks = getBlocks(container) await focusAtEnd(getEditableInBlock(blocks[0])) dispatchPaste(blocks[0], '!') @@ -872,22 +832,22 @@ describe('Feature: block keyboard navigation', () => { expect(getBlocks(container)).toHaveLength(5) }) - it('should update raw value when pasting text at end of a mark block', async () => { - const {container} = await render() + it('should update raw value when pasting text at end of a mark row', async () => { + const {container} = await render() const blocks = getBlocks(container) - // block[0] raw = '# Welcome to **Marked Input**' + // block[0] raw = '# Welcome to Draggable Blocks\n\n' await focusAtEnd(blocks[0]) dispatchPaste(blocks[0], '!') await new Promise(r => setTimeout(r, 50)) const block0Raw = getRawValue(container).split('\n\n')[0] - expect(block0Raw).toBe('# Welcome to **Marked Input**!') + expect(block0Raw).toBe('# Welcome to Draggable Blocks!') }) }) - describe('Enter mid-block split', () => { - it('should increase block count by 1', async () => { - const {container} = await render() + describe('Enter mid-row split', () => { + it('should increase row count by 1', async () => { + const {container} = await render() const editable = getEditableInBlock(getBlocks(container)[0]) await userEvent.click(editable) @@ -898,45 +858,43 @@ describe('Feature: block keyboard navigation', () => { expect(getBlocks(container)).toHaveLength(6) }) - it('should put text before caret in current block', async () => { - const {container} = await render() + it('should put text before caret in current row', async () => { + const {container} = await render() const editable = getEditableInBlock(getBlocks(container)[0]) await userEvent.click(editable) await userEvent.keyboard('{Home}') - // Position after "First" await userEvent.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}') await userEvent.keyboard('{Enter}') const raw = getRawValue(container) - const blockTexts = raw.split('\n\n') - expect(blockTexts[0]).toBe('First') + const rowTexts = raw.split('\n\n') + expect(rowTexts[0]).toBe('First') }) - it('should put text after caret in new block', async () => { - const {container} = await render() + it('should put text after caret in new row', async () => { + const {container} = await render() const editable = getEditableInBlock(getBlocks(container)[0]) await userEvent.click(editable) await userEvent.keyboard('{Home}') - // Position after "First" await userEvent.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}') await userEvent.keyboard('{Enter}') const raw = getRawValue(container) - const blockTexts = raw.split('\n\n') - expect(blockTexts[1]).toBe(' block of plain text') + const rowTexts = raw.split('\n\n') + expect(rowTexts[1]).toBe(' block of plain text') }) - it('should not expose raw markdown syntax in block[0] after Enter with marks', async () => { - const {container} = await render() + it('should insert new empty row after mark row when pressing Enter on mark', async () => { + const {container} = await render() const blocks = getBlocks(container) await focusAtEnd(blocks[0]) await userEvent.keyboard('{Enter}') const raw = getRawValue(container) - // The separator after the mark should still be intact - expect(raw).toContain('**Marked Input**\n\n') + // The h1 mark row must remain intact + expect(raw).toContain('# Welcome to Draggable Blocks\n\n') }) }) }) \ No newline at end of file diff --git a/packages/react/storybook/src/pages/Drag/Drag.stories.tsx b/packages/react/storybook/src/pages/Drag/Drag.stories.tsx index c269cc15..aabcd678 100644 --- a/packages/react/storybook/src/pages/Drag/Drag.stories.tsx +++ b/packages/react/storybook/src/pages/Drag/Drag.stories.tsx @@ -1,10 +1,12 @@ import {MarkedInput} from '@markput/react' import type {MarkProps, Option} from '@markput/react' import type {Meta, StoryObj} from '@storybook/react-vite' -import type {CSSProperties} from 'react' +import type {CSSProperties, ReactNode} from 'react' import {useState} from 'react' import {Text} from '../../shared/components/Text' +import {COMPLEX_MARKDOWN} from '../../shared/lib/sampleTexts' +import {blockLevelMarkdownOptions} from '../Nested/MarkdownOptions' export default { title: 'MarkedInput/Drag', @@ -212,4 +214,268 @@ export const NestedMarks: Story = { ) }, +} + +// ─── Plain text rows ────────────────────────────────────────────────────────── + +export const PlainTextDrag: Story = { + render: () => { + const [value, setValue] = useState(`First block of plain text + +Second block of plain text + +Third block of plain text + +Fourth block of plain text + +Fifth block of plain text`) + + return ( +
+ + +
+ ) + }, +} + +// ─── Markdown with block-level marks (headings + list) ──────────────────────── + +const MarkdownMark = ({ + children, + value, + style, +}: { + value?: string + children?: ReactNode + style?: React.CSSProperties +}) => {children || value} + +export const MarkdownDrag: Story = { + render: () => { + const [value, setValue] = useState(`# Welcome to Draggable Blocks + +This is the first paragraph. Hover to see the drag handle on the left. + +This is the second paragraph. Try dragging it above the first one! + +## Features + +- Drag handles appear on hover +- Drop indicators show where the block will land +- Blocks reorder by manipulating the underlying string`) + + return ( +
+ + +
+ ) + }, +} + +export const MarkdownDocumentDrag: Story = { + render: () => { + const [value, setValue] = useState(COMPLEX_MARKDOWN) + + return ( +
+ + +
+ ) + }, +} + +export const ReadOnlyDrag: Story = { + render: () => { + const value = `# Read-Only Mode + +Drag handles are hidden in read-only mode. + +## Section A + +Content cannot be reordered. + +## Section B + +This is static content.` + + return ( +
+ +
+ ) + }, +} + +// ─── Todo list (all marks include \n\n) ─────────────────────────────────────── + +interface TodoMarkProps extends MarkProps { + style?: CSSProperties + todo?: 'pending' | 'done' +} + +const todoOptions: Option[] = [ + { + markup: '# __nested__\n\n', + mark: (props: MarkProps) => ({ + ...props, + style: {display: 'block', fontSize: '1.4em', fontWeight: 'bold', margin: '0.3em 0'} as CSSProperties, + }), + }, + { + markup: '- [ ] __nested__\n\n', + mark: (props: MarkProps) => ({...props, todo: 'pending' as const, style: {display: 'block'} as CSSProperties}), + }, + { + markup: '- [x] __nested__\n\n', + mark: (props: MarkProps) => ({ + ...props, + todo: 'done' as const, + style: {display: 'block', textDecoration: 'line-through', opacity: 0.5} as CSSProperties, + }), + }, + { + markup: '\t- [ ] __nested__\n\n', + mark: (props: MarkProps) => ({ + ...props, + todo: 'pending' as const, + style: {display: 'block', paddingLeft: '1.5em'} as CSSProperties, + }), + }, + { + markup: '\t- [x] __nested__\n\n', + mark: (props: MarkProps) => ({ + ...props, + todo: 'done' as const, + style: { + display: 'block', + paddingLeft: '1.5em', + textDecoration: 'line-through', + opacity: 0.5, + } as CSSProperties, + }), + }, + { + markup: '\t\t- [ ] __nested__\n\n', + mark: (props: MarkProps) => ({ + ...props, + todo: 'pending' as const, + style: {display: 'block', paddingLeft: '3em'} as CSSProperties, + }), + }, + { + markup: '\t\t- [x] __nested__\n\n', + mark: (props: MarkProps) => ({ + ...props, + todo: 'done' as const, + style: { + display: 'block', + paddingLeft: '3em', + textDecoration: 'line-through', + opacity: 0.5, + } as CSSProperties, + }), + }, + { + markup: '> __nested__\n\n', + mark: (props: MarkProps) => ({ + ...props, + style: {display: 'block', fontSize: '0.85em', color: '#888', fontStyle: 'italic'} as CSSProperties, + }), + }, +] + +const TodoMark = ({children, value, style, todo}: TodoMarkProps) => ( + + {todo && {todo === 'done' ? '\u2611' : '\u2610'}} + {children || value} + +) + +const TODO_VALUE = `# \u{1F4CB} Project Launch Checklist + +- [ ] Design Phase + +\t- [ ] Create wireframes + +\t- [x] Define color palette + +\t- [ ] Design component library + +- [x] Research + +\t- [x] Analyze competitors + +\t- [x] User interviews + +\t\t- [x] Draft interview questions + +\t\t- [x] Schedule 5 sessions + +- [ ] Development + +\t- [ ] Set up CI/CD pipeline + +\t- [x] Write unit tests + +\t- [ ] API integration + +\t\t- [ ] Auth endpoints + +\t\t- [ ] Data sync + +- [ ] Launch + +\t- [ ] Final QA pass + +\t- [ ] Deploy to production + +> \u2610 = pending \u2611 = done` + +export const TodoListDrag: Story = { + render: () => { + const [value, setValue] = useState(TODO_VALUE) + + return ( +
+ + +
+ ) + }, } \ No newline at end of file diff --git a/packages/react/storybook/src/pages/Nested/MarkdownOptions.ts b/packages/react/storybook/src/pages/Nested/MarkdownOptions.ts index 32616235..cb4c3ad6 100644 --- a/packages/react/storybook/src/pages/Nested/MarkdownOptions.ts +++ b/packages/react/storybook/src/pages/Nested/MarkdownOptions.ts @@ -112,4 +112,11 @@ function buildMarkdownOptions(theme: Record): Option[] { /** * Markdown options ready for MarkedInput */ -export const markdownOptions = buildMarkdownOptions(defaultMarkdownTheme) \ No newline at end of file +export const markdownOptions = buildMarkdownOptions(defaultMarkdownTheme) + +/** + * Block-level markdown options only (those whose markup includes \n\n). + * Use in drag mode so inline marks (bold, italic, code, link, strikethrough) + * are not each split into their own draggable row. + */ +export const blockLevelMarkdownOptions = markdownOptions.filter(opt => (opt.markup as string).includes('\n\n')) \ No newline at end of file diff --git a/packages/vue/storybook/src/pages/Block/Block.stories.ts b/packages/vue/storybook/src/pages/Block/Block.stories.ts deleted file mode 100644 index e2df0762..00000000 --- a/packages/vue/storybook/src/pages/Block/Block.stories.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type {MarkProps, Markup, Option} from '@markput/vue' -import {MarkedInput} from '@markput/vue' -import type {Meta, StoryObj} from '@storybook/vue3-vite' -import {defineComponent, h, ref} from 'vue' - -import Text from '../../shared/components/Text.vue' - -export default { - title: 'MarkedInput/Block', - tags: ['autodocs'], - component: MarkedInput, - parameters: { - docs: { - description: { - component: - 'Notion-like draggable blocks. Hover over a block to reveal the + and drag handle buttons. Drag to reorder, click + to add a block, click the grip to open a block menu (delete/duplicate).', - }, - }, - }, -} satisfies Meta - -type Story = StoryObj> - -const MarkdownMark = defineComponent({ - props: {value: String, children: {type: null}, style: {type: Object}}, - setup(props, {slots}) { - return () => - h( - 'span', - {style: {...(props.style as Record), margin: '0 1px'}}, - slots.default?.() ?? props.value - ) - }, -}) - -const h1Style = {display: 'block', fontSize: '2em', fontWeight: 'bold', margin: '0.5em 0'} - -const markdownOptions: Option[] = [ - { - markup: '# __nested__\n\n' as Markup, - mark: (props: MarkProps) => ({...props, style: h1Style}), - }, -] as Option[] - -const containerStyle = {maxWidth: '700px', margin: '0 auto', paddingLeft: '52px'} -const editorStyle = {minHeight: '200px', padding: '12px', border: '1px solid #e0e0e0', borderRadius: '8px'} - -export const BasicDraggable: Story = { - render: () => - defineComponent({ - setup() { - const value = ref(`# Welcome to Draggable Blocks - -This is the first paragraph. Hover to see the drag handle on the left. - -This is the second paragraph. Try dragging it above the first one! - -## Features - -- Drag handles appear on hover -- Drop indicators show where the block will land -- Blocks reorder by manipulating the underlying string`) - return () => - h('div', {style: containerStyle}, [ - h(MarkedInput, { - Mark: MarkdownMark, - options: markdownOptions, - value: value.value, - block: true, - style: editorStyle, - onChange: (v: string) => { - value.value = v - }, - }), - h(Text, {label: 'Raw value:', value: value.value}), - ]) - }, - }), -} - -export const MarkdownDocument: Story = { - render: () => - defineComponent({ - setup() { - const value = ref(`# Welcome to **Marked Input** - -This is a *powerful* library for parsing **rich text** with *markdown* formatting. -You can use \`inline code\` snippets like \`const parser = new ParserV2()\` in your text. - -## Features - -- **Bold text** with **strong emphasis** -- *Italic text* and *emphasis* support -- \`Code snippets\` and \`code blocks\` -- ~~Strikethrough~~ for deleted content -- Links like [GitHub](https://github.com) - -## Example - -Here's how to use it: - -\`\`\`javascript -const parser = new ParserV2(['**__value__**', '*__value__*']) -const result = parser.parse('Hello **world**!') -\`\`\` - -Visit our [documentation](https://docs.example.com) for more details. -~~This feature is deprecated~~ and will be removed in v3.0.`) - return () => - h('div', {style: containerStyle}, [ - h(MarkedInput, { - Mark: MarkdownMark, - options: markdownOptions, - value: value.value, - block: true, - style: editorStyle, - onChange: (v: string) => { - value.value = v - }, - }), - h(Text, {label: 'Raw value:', value: value.value}), - ]) - }, - }), -} - -export const ReadOnlyDraggable: Story = { - render: () => - defineComponent({ - setup() { - const value = `# Read-Only Mode - -Drag handles are hidden in read-only mode. - -## Section A - -Content cannot be reordered. - -## Section B - -This is static content.` - return () => - h('div', {style: containerStyle}, [ - h(MarkedInput, { - Mark: MarkdownMark, - options: markdownOptions, - value, - readOnly: true, - block: true, - style: editorStyle, - }), - ]) - }, - }), -} - -export const PlainTextBlocks: Story = { - render: () => - defineComponent({ - setup() { - const value = ref(`First block of plain text - -Second block of plain text - -Third block of plain text - -Fourth block of plain text - -Fifth block of plain text`) - return () => - h('div', {style: containerStyle}, [ - h(MarkedInput, { - value: value.value, - block: true, - style: editorStyle, - onChange: (v: string) => { - value.value = v - }, - }), - h(Text, {label: 'Raw value:', value: value.value}), - ]) - }, - }), -} \ No newline at end of file diff --git a/packages/vue/storybook/src/pages/Block/Block.spec.ts b/packages/vue/storybook/src/pages/Drag/Drag.spec.ts similarity index 64% rename from packages/vue/storybook/src/pages/Block/Block.spec.ts rename to packages/vue/storybook/src/pages/Drag/Drag.spec.ts index 68441b65..29a8d48f 100644 --- a/packages/vue/storybook/src/pages/Block/Block.spec.ts +++ b/packages/vue/storybook/src/pages/Drag/Drag.spec.ts @@ -4,9 +4,9 @@ import {render} from 'vitest-browser-vue' import {page, userEvent} from 'vitest/browser' import {focusAtEnd, focusAtStart} from '../../shared/lib/focus' -import * as BlockStories from './Block.stories' +import * as DragStories from './Drag.stories' -const {BasicDraggable, MarkdownDocument, PlainTextBlocks, ReadOnlyDraggable} = composeStories(BlockStories) +const {PlainTextDrag, MarkdownDrag, ReadOnlyDrag} = composeStories(DragStories) const GRIP_SELECTOR = 'button[aria-label="Drag to reorder or click for options"]' @@ -70,29 +70,24 @@ async function openMenuForGrip(container: Element, gripIndex: number) { await userEvent.click(grip) } -describe('Feature: blocks', () => { - it('should render 6 blocks for BasicDraggable', async () => { - const {container} = await render(BasicDraggable) - expect(getGrips(container)).toHaveLength(6) - }) - - it('should render 10 blocks for MarkdownDocument', async () => { - const {container} = await render(MarkdownDocument) - expect(getGrips(container)).toHaveLength(10) +describe('Feature: drag rows', () => { + it('should render 5 rows for PlainTextDrag', async () => { + const {container} = await render(PlainTextDrag) + expect(getGrips(container)).toHaveLength(5) }) - it('should render 5 blocks for PlainTextBlocks', async () => { - const {container} = await render(PlainTextBlocks) + it('should render 5 rows for MarkdownDrag', async () => { + const {container} = await render(MarkdownDrag) expect(getGrips(container)).toHaveLength(5) }) it('should render no grip buttons in read-only mode', async () => { - const {container} = await render(ReadOnlyDraggable) + const {container} = await render(ReadOnlyDrag) expect(getGrips(container)).toHaveLength(0) }) it('should render content in read-only mode', async () => { - await render(ReadOnlyDraggable) + await render(ReadOnlyDrag) await expect.element(page.getByText(/Read-Only/).first()).toBeInTheDocument() await expect.element(page.getByText(/Section A/).first()).toBeInTheDocument() await expect.element(page.getByText(/Section B/).first()).toBeInTheDocument() @@ -100,7 +95,7 @@ describe('Feature: blocks', () => { describe('menu', () => { it('should open with Add below, Duplicate, Delete options', async () => { - const {container} = await render(PlainTextBlocks) + const {container} = await render(PlainTextDrag) await openMenuForGrip(container, 0) await expect.element(page.getByText('Add below')).toBeInTheDocument() @@ -109,7 +104,7 @@ describe('Feature: blocks', () => { }) it('should close on Escape', async () => { - const {container} = await render(PlainTextBlocks) + const {container} = await render(PlainTextDrag) await openMenuForGrip(container, 0) await expect.element(page.getByText('Add below')).toBeInTheDocument() @@ -118,7 +113,7 @@ describe('Feature: blocks', () => { }) it('should close when clicking outside', async () => { - const {container} = await render(PlainTextBlocks) + const {container} = await render(PlainTextDrag) await openMenuForGrip(container, 0) await expect.element(page.getByText('Add below')).toBeInTheDocument() @@ -127,33 +122,33 @@ describe('Feature: blocks', () => { }) }) - describe('add block', () => { - it('should increase block count by 1 when adding below first block', async () => { - const {container} = await render(PlainTextBlocks) + describe('add row', () => { + it('should increase row count by 1 when adding below first row', async () => { + const {container} = await render(PlainTextDrag) await openMenuForGrip(container, 0) await userEvent.click(page.getByText('Add below').element()) expect(getGrips(container)).toHaveLength(6) }) - it('should increase block count by 1 when adding below middle block', async () => { - const {container} = await render(PlainTextBlocks) + it('should increase row count by 1 when adding below middle row', async () => { + const {container} = await render(PlainTextDrag) await openMenuForGrip(container, 2) await userEvent.click(page.getByText('Add below').element()) expect(getGrips(container)).toHaveLength(6) }) - it('should increase block count by 1 when adding below last block', async () => { - const {container} = await render(PlainTextBlocks) + it('should increase row count by 1 when adding below last row', async () => { + const {container} = await render(PlainTextDrag) await openMenuForGrip(container, 4) await userEvent.click(page.getByText('Add below').element()) expect(getGrips(container)).toHaveLength(6) }) - it('should insert an empty block between the target and next block', async () => { - const {container} = await render(PlainTextBlocks) + it('should insert an empty row between the target and next row', async () => { + const {container} = await render(PlainTextDrag) await openMenuForGrip(container, 0) await userEvent.click(page.getByText('Add below').element()) @@ -161,8 +156,8 @@ describe('Feature: blocks', () => { expect(raw).toContain('First block of plain text\n\n\n\nSecond block of plain text') }) - it('should not create a trailing separator when adding below last block', async () => { - const {container} = await render(PlainTextBlocks) + it('should not create a trailing separator when adding below last row', async () => { + const {container} = await render(PlainTextDrag) await openMenuForGrip(container, 4) await userEvent.click(page.getByText('Add below').element()) @@ -170,8 +165,8 @@ describe('Feature: blocks', () => { expect(raw.endsWith('\n\n\n\n')).toBe(false) }) - it('should result in a single empty block when all blocks are deleted', async () => { - const {container} = await render(PlainTextBlocks) + it('should result in a single empty row when all rows are deleted', async () => { + const {container} = await render(PlainTextDrag) // eslint-disable-next-line no-await-in-loop for (let i = 4; i > 0; i--) { @@ -185,17 +180,17 @@ describe('Feature: blocks', () => { }) }) - describe('delete block', () => { - it('should decrease count by 1 when deleting middle block', async () => { - const {container} = await render(PlainTextBlocks) + describe('delete row', () => { + it('should decrease count by 1 when deleting middle row', async () => { + const {container} = await render(PlainTextDrag) await openMenuForGrip(container, 2) await userEvent.click(page.getByText('Delete').element()) expect(getGrips(container)).toHaveLength(4) }) - it('should preserve remaining content when deleting first block', async () => { - const {container} = await render(PlainTextBlocks) + it('should preserve remaining content when deleting first row', async () => { + const {container} = await render(PlainTextDrag) await openMenuForGrip(container, 0) await userEvent.click(page.getByText('Delete').element()) @@ -203,8 +198,8 @@ describe('Feature: blocks', () => { expect(getRawValue(container)).toContain('Second block of plain text') }) - it('should decrease count by 1 when deleting last block', async () => { - const {container} = await render(PlainTextBlocks) + it('should decrease count by 1 when deleting last row', async () => { + const {container} = await render(PlainTextDrag) await openMenuForGrip(container, 4) await userEvent.click(page.getByText('Delete').element()) @@ -213,8 +208,8 @@ describe('Feature: blocks', () => { expect(getRawValue(container)).not.toContain('Fifth block of plain text') }) - it('should result in empty value when deleting the last remaining block', async () => { - const {container} = await render(PlainTextBlocks) + it('should result in empty value when deleting the last remaining row', async () => { + const {container} = await render(PlainTextDrag) // eslint-disable-next-line no-await-in-loop for (let i = 4; i > 0; i--) { @@ -231,9 +226,9 @@ describe('Feature: blocks', () => { }) }) - describe('duplicate block', () => { - it('should increase count by 1 when duplicating first block', async () => { - const {container} = await render(PlainTextBlocks) + describe('duplicate row', () => { + it('should increase count by 1 when duplicating first row', async () => { + const {container} = await render(PlainTextDrag) await openMenuForGrip(container, 0) await userEvent.click(page.getByText('Duplicate').element()) @@ -241,7 +236,7 @@ describe('Feature: blocks', () => { }) it('should create a copy with the same text content', async () => { - const {container} = await render(PlainTextBlocks) + const {container} = await render(PlainTextDrag) await openMenuForGrip(container, 0) await userEvent.click(page.getByText('Duplicate').element()) @@ -249,8 +244,8 @@ describe('Feature: blocks', () => { expect(matches).toHaveLength(2) }) - it('should increase count by 1 when duplicating last block', async () => { - const {container} = await render(PlainTextBlocks) + it('should increase count by 1 when duplicating last row', async () => { + const {container} = await render(PlainTextDrag) await openMenuForGrip(container, 4) await userEvent.click(page.getByText('Duplicate').element()) @@ -259,8 +254,8 @@ describe('Feature: blocks', () => { }) describe('enter key', () => { - it('should create a new block when pressing Enter at end of block', async () => { - const {container} = await render(PlainTextBlocks) + it('should create a new row when pressing Enter at end of text row', async () => { + const {container} = await render(PlainTextDrag) expect(getGrips(container)).toHaveLength(5) const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0])) @@ -270,8 +265,8 @@ describe('Feature: blocks', () => { expect(getGrips(container)).toHaveLength(6) }) - it('should preserve all block content after pressing Enter', async () => { - const {container} = await render(PlainTextBlocks) + it('should preserve all row content after pressing Enter', async () => { + const {container} = await render(PlainTextDrag) const originalValue = getRawValue(container) const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0])) @@ -284,8 +279,8 @@ describe('Feature: blocks', () => { expect(newValue).toContain('Fifth block of plain text') }) - it('should not create a new block when pressing Shift+Enter', async () => { - const {container} = await render(PlainTextBlocks) + it('should not create a new row when pressing Shift+Enter', async () => { + const {container} = await render(PlainTextDrag) const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0])) await focusAtEnd(editable) @@ -296,8 +291,8 @@ describe('Feature: blocks', () => { }) describe('drag & drop', () => { - it('should reorder blocks when dragging block 0 after block 2', async () => { - const {container} = await render(PlainTextBlocks) + it('should reorder rows when dragging row 0 after row 2', async () => { + const {container} = await render(PlainTextDrag) await simulateDragBlock(container, 0, 2) @@ -305,8 +300,8 @@ describe('Feature: blocks', () => { expect(raw.indexOf('First block of plain text')).toBeGreaterThan(raw.indexOf('Third block of plain text')) }) - it('should not change order when dragging block onto itself', async () => { - const {container} = await render(PlainTextBlocks) + it('should not change order when dragging row onto itself', async () => { + const {container} = await render(PlainTextDrag) const original = getRawValue(container) await simulateDragBlock(container, 1, 1) @@ -315,9 +310,9 @@ describe('Feature: blocks', () => { }) }) - describe('backspace on empty block', () => { - it('should delete the block and reduce count by 1', async () => { - const {container} = await render(PlainTextBlocks) + describe('backspace on empty row', () => { + it('should delete the row and reduce count by 1', async () => { + const {container} = await render(PlainTextDrag) await openMenuForGrip(container, 0) await userEvent.click(page.getByText('Add below').element()) @@ -330,8 +325,8 @@ describe('Feature: blocks', () => { expect(getGrips(container)).toHaveLength(5) }) - it('should not delete a non-empty block on Backspace', async () => { - const {container} = await render(PlainTextBlocks) + it('should not delete a non-empty row on Backspace', async () => { + const {container} = await render(PlainTextDrag) const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0])) await focusAtEnd(editable) await userEvent.keyboard('{Backspace}') @@ -340,8 +335,8 @@ describe('Feature: blocks', () => { }) }) - it('should focus a block after Add below', async () => { - const {container} = await render(PlainTextBlocks) + it('should focus a row after Add below', async () => { + const {container} = await render(PlainTextDrag) await openMenuForGrip(container, 0) await userEvent.click(page.getByText('Add below').element()) @@ -349,8 +344,8 @@ describe('Feature: blocks', () => { expect(activeEl?.closest('[data-testid="block"]')).toBeTruthy() }) - it('should split block at caret when pressing Enter at the beginning', async () => { - const {container} = await render(PlainTextBlocks) + it('should split row at caret when pressing Enter at the beginning', async () => { + const {container} = await render(PlainTextDrag) const editable = getEditableInBlock(getBlockDiv(getGrips(container)[0])) await focusAtStart(editable) await userEvent.keyboard('{Enter}') @@ -360,7 +355,7 @@ describe('Feature: blocks', () => { }) it('should restore original value after add then delete', async () => { - const {container} = await render(PlainTextBlocks) + const {container} = await render(PlainTextDrag) const original = getRawValue(container) await openMenuForGrip(container, 0) @@ -375,7 +370,7 @@ describe('Feature: blocks', () => { }) it('should restore original value after duplicate then delete', async () => { - const {container} = await render(PlainTextBlocks) + const {container} = await render(PlainTextDrag) const original = getRawValue(container) await openMenuForGrip(container, 0) @@ -434,10 +429,10 @@ function dispatchInsertText(target: HTMLElement, text: string) { ) } -describe('Feature: block keyboard navigation', () => { - describe('ArrowLeft cross-block', () => { - it('should move focus to previous block when at start of block', async () => { - const {container} = await render(PlainTextBlocks) +describe('Feature: drag row keyboard navigation', () => { + describe('ArrowLeft cross-row', () => { + it('should move focus to previous row when at start of row', async () => { + const {container} = await render(PlainTextDrag) const blocks = getBlocks(container) await focusAtStart(getEditableInBlock(blocks[1])) @@ -446,8 +441,8 @@ describe('Feature: block keyboard navigation', () => { expect(document.activeElement).toBe(blocks[0]) }) - it('should not cross to previous block when caret is mid-block', async () => { - const {container} = await render(PlainTextBlocks) + it('should not cross to previous row when caret is mid-row', async () => { + const {container} = await render(PlainTextDrag) const blocks = getBlocks(container) await focusAtEnd(getEditableInBlock(blocks[1])) @@ -456,8 +451,8 @@ describe('Feature: block keyboard navigation', () => { expect(document.activeElement).toBe(blocks[1]) }) - it('should not cross block boundary from the first block', async () => { - const {container} = await render(PlainTextBlocks) + it('should not cross row boundary from the first row', async () => { + const {container} = await render(PlainTextDrag) const blocks = getBlocks(container) await focusAtStart(getEditableInBlock(blocks[0])) @@ -467,9 +462,9 @@ describe('Feature: block keyboard navigation', () => { }) }) - describe('ArrowRight cross-block', () => { - it('should move focus to next block when at end of block', async () => { - const {container} = await render(PlainTextBlocks) + describe('ArrowRight cross-row', () => { + it('should move focus to next row when at end of row', async () => { + const {container} = await render(PlainTextDrag) const blocks = getBlocks(container) await focusAtEnd(getEditableInBlock(blocks[0])) @@ -478,8 +473,8 @@ describe('Feature: block keyboard navigation', () => { expect(document.activeElement).toBe(blocks[1]) }) - it('should not cross to next block when caret is mid-block', async () => { - const {container} = await render(PlainTextBlocks) + it('should not cross to next row when caret is mid-row', async () => { + const {container} = await render(PlainTextDrag) const blocks = getBlocks(container) await focusAtStart(getEditableInBlock(blocks[0])) @@ -488,8 +483,8 @@ describe('Feature: block keyboard navigation', () => { expect(document.activeElement).toBe(blocks[0]) }) - it('should not cross block boundary from the last block', async () => { - const {container} = await render(PlainTextBlocks) + it('should not cross row boundary from the last row', async () => { + const {container} = await render(PlainTextDrag) const blocks = getBlocks(container) const last = blocks[blocks.length - 1] @@ -500,9 +495,9 @@ describe('Feature: block keyboard navigation', () => { }) }) - describe('ArrowDown cross-block', () => { - it('should move focus to next block when on last line of block', async () => { - const {container} = await render(PlainTextBlocks) + describe('ArrowDown cross-row', () => { + it('should move focus to next row when on last line of row', async () => { + const {container} = await render(PlainTextDrag) const blocks = getBlocks(container) await focusAtEnd(getEditableInBlock(blocks[0])) @@ -511,8 +506,8 @@ describe('Feature: block keyboard navigation', () => { expect(document.activeElement).toBe(blocks[1]) }) - it('should not cross block boundary from the last block', async () => { - const {container} = await render(PlainTextBlocks) + it('should not cross row boundary from the last row', async () => { + const {container} = await render(PlainTextDrag) const blocks = getBlocks(container) const last = blocks[blocks.length - 1] @@ -523,9 +518,9 @@ describe('Feature: block keyboard navigation', () => { }) }) - describe('ArrowUp cross-block', () => { - it('should move focus to previous block when on first line of block', async () => { - const {container} = await render(PlainTextBlocks) + describe('ArrowUp cross-row', () => { + it('should move focus to previous row when on first line of row', async () => { + const {container} = await render(PlainTextDrag) const blocks = getBlocks(container) await focusAtStart(getEditableInBlock(blocks[1])) @@ -534,8 +529,8 @@ describe('Feature: block keyboard navigation', () => { expect(document.activeElement).toBe(blocks[0]) }) - it('should not cross block boundary from the first block', async () => { - const {container} = await render(PlainTextBlocks) + it('should not cross row boundary from the first row', async () => { + const {container} = await render(PlainTextDrag) const blocks = getBlocks(container) await focusAtStart(getEditableInBlock(blocks[0])) @@ -545,9 +540,9 @@ describe('Feature: block keyboard navigation', () => { }) }) - describe('Backspace merge blocks', () => { - it('should merge with previous block when Backspace pressed at start of non-empty block', async () => { - const {container} = await render(PlainTextBlocks) + describe('Backspace merge rows (text+text)', () => { + it('should merge with previous text row when Backspace pressed at start of non-empty row', async () => { + const {container} = await render(PlainTextDrag) const before = getBlocks(container).length await focusAtStart(getEditableInBlock(getBlocks(container)[1])) @@ -556,45 +551,33 @@ describe('Feature: block keyboard navigation', () => { expect(getBlocks(container)).toHaveLength(before - 1) }) - describe('Backspace into a mark block (heading with embedded \\n\\n separator)', () => { - // Bug: blocks whose mark token includes the \n\n separator have endPos === next block's startPos. - // mergeBlocks must detect this and strip the separator from inside the mark. + describe('Backspace at start of text row after a mark row (navigate-only in drag mode)', () => { + // In drag mode, mark→text boundary is navigate-only: Backspace moves focus + // to the mark row but does NOT merge. - it('should reduce block count when Backspace at start of block after heading mark', async () => { - const {container} = await render(MarkdownDocument) + it('should NOT reduce row count when Backspace at start of text row after mark row', async () => { + const {container} = await render(MarkdownDrag) const before = getBlocks(container).length - // block[1] is "This is a powerful..." which follows the heading mark (block[0]) await focusAtStart(getEditableInBlock(getBlocks(container)[1])) await userEvent.keyboard('{Backspace}') - expect(getBlocks(container)).toHaveLength(before - 1) + expect(getBlocks(container)).toHaveLength(before) }) - it('should preserve content of both blocks after merging into heading mark', async () => { - const {container} = await render(MarkdownDocument) + it('should move focus to the mark row on Backspace at mark boundary', async () => { + const {container} = await render(MarkdownDrag) + const markBlock = getBlocks(container)[0] await focusAtStart(getEditableInBlock(getBlocks(container)[1])) await userEvent.keyboard('{Backspace}') - const raw = getRawValue(container) - expect(raw).toContain('Marked Input') - expect(raw).toContain('powerful') - }) - - it('should keep focus in the heading block after Backspace merge', async () => { - const {container} = await render(MarkdownDocument) - const headingBlock = getBlocks(container)[0] - - await focusAtStart(getEditableInBlock(getBlocks(container)[1])) - await userEvent.keyboard('{Backspace}') - - expect(document.activeElement).toBe(headingBlock) + expect(document.activeElement).toBe(markBlock) }) }) - it('should preserve content of both merged blocks', async () => { - const {container} = await render(PlainTextBlocks) + it('should preserve content of both merged rows', async () => { + const {container} = await render(PlainTextDrag) await focusAtStart(getEditableInBlock(getBlocks(container)[1])) await userEvent.keyboard('{Backspace}') @@ -604,8 +587,8 @@ describe('Feature: block keyboard navigation', () => { expect(raw).toContain('Second block of plain text') }) - it('should keep focus in the previous block after merge', async () => { - const {container} = await render(PlainTextBlocks) + it('should keep focus in the previous row after merge', async () => { + const {container} = await render(PlainTextDrag) const blocks = getBlocks(container) const prevBlock = blocks[0] @@ -615,8 +598,8 @@ describe('Feature: block keyboard navigation', () => { expect(document.activeElement).toBe(prevBlock) }) - it('should only delete one block at a time on Backspace', async () => { - const {container} = await render(PlainTextBlocks) + it('should only delete one row at a time on Backspace', async () => { + const {container} = await render(PlainTextDrag) expect(getBlocks(container)).toHaveLength(5) await focusAtStart(getEditableInBlock(getBlocks(container)[1])) @@ -626,10 +609,10 @@ describe('Feature: block keyboard navigation', () => { }) }) - describe('Delete merge blocks', () => { - describe('Delete at end of block', () => { - it('should merge with next block when Delete pressed at end of non-last block', async () => { - const {container} = await render(PlainTextBlocks) + describe('Delete merge rows', () => { + describe('Delete at end of row', () => { + it('should merge with next row when Delete pressed at end of non-last row', async () => { + const {container} = await render(PlainTextDrag) const before = getBlocks(container).length await focusAtEnd(getEditableInBlock(getBlocks(container)[0])) @@ -638,8 +621,8 @@ describe('Feature: block keyboard navigation', () => { expect(getBlocks(container)).toHaveLength(before - 1) }) - it('should preserve content of both merged blocks', async () => { - const {container} = await render(PlainTextBlocks) + it('should preserve content of both merged rows', async () => { + const {container} = await render(PlainTextDrag) await focusAtEnd(getEditableInBlock(getBlocks(container)[0])) await userEvent.keyboard('{Delete}') @@ -649,8 +632,8 @@ describe('Feature: block keyboard navigation', () => { expect(raw).toContain('Second block of plain text') }) - it('should keep focus in the current block after Delete merge', async () => { - const {container} = await render(PlainTextBlocks) + it('should keep focus in the current row after Delete merge', async () => { + const {container} = await render(PlainTextDrag) const currentBlock = getBlocks(container)[0] await focusAtEnd(getEditableInBlock(currentBlock)) @@ -659,8 +642,8 @@ describe('Feature: block keyboard navigation', () => { expect(document.activeElement).toBe(currentBlock) }) - it('should not merge when Delete pressed at end of last block', async () => { - const {container} = await render(PlainTextBlocks) + it('should not merge when Delete pressed at end of last row', async () => { + const {container} = await render(PlainTextDrag) const blocks = getBlocks(container) const last = blocks[blocks.length - 1] @@ -671,9 +654,9 @@ describe('Feature: block keyboard navigation', () => { }) }) - describe('Delete at start of block', () => { - it('should merge current block into previous when Delete pressed at start of non-first block', async () => { - const {container} = await render(PlainTextBlocks) + describe('Delete at start of row', () => { + it('should merge current row into previous when Delete pressed at start of non-first row', async () => { + const {container} = await render(PlainTextDrag) const before = getBlocks(container).length await focusAtStart(getEditableInBlock(getBlocks(container)[1])) @@ -682,8 +665,8 @@ describe('Feature: block keyboard navigation', () => { expect(getBlocks(container)).toHaveLength(before - 1) }) - it('should preserve content of both merged blocks', async () => { - const {container} = await render(PlainTextBlocks) + it('should preserve content of both merged rows', async () => { + const {container} = await render(PlainTextDrag) await focusAtStart(getEditableInBlock(getBlocks(container)[1])) await userEvent.keyboard('{Delete}') @@ -693,8 +676,8 @@ describe('Feature: block keyboard navigation', () => { expect(raw).toContain('Second block of plain text') }) - it('should move focus to the previous block after merge', async () => { - const {container} = await render(PlainTextBlocks) + it('should move focus to the previous row after merge', async () => { + const {container} = await render(PlainTextDrag) const prevBlock = getBlocks(container)[0] await focusAtStart(getEditableInBlock(getBlocks(container)[1])) @@ -703,8 +686,8 @@ describe('Feature: block keyboard navigation', () => { expect(document.activeElement).toBe(prevBlock) }) - it('should not merge when Delete pressed at start of the first block', async () => { - const {container} = await render(PlainTextBlocks) + it('should not merge when Delete pressed at start of the first row', async () => { + const {container} = await render(PlainTextDrag) await focusAtStart(getEditableInBlock(getBlocks(container)[0])) await userEvent.keyboard('{Delete}') @@ -713,67 +696,50 @@ describe('Feature: block keyboard navigation', () => { }) it('should place caret at the join point after merge', async () => { - const {container} = await render(PlainTextBlocks) + const {container} = await render(PlainTextDrag) await focusAtStart(getEditableInBlock(getBlocks(container)[1])) await userEvent.keyboard('{Delete}') - // After merge, typing should insert right at the join point - // (between the end of block 0 and start of block 1 text) const raw = getRawValue(container) expect(raw).toContain('First block of plain textSecond block of plain text') }) }) - describe('Delete into a mark block (heading with embedded \\n\\n separator)', () => { - // Bug: blocks whose mark token includes the \n\n separator have endPos === next block's startPos. - // mergeBlocks must detect this and strip the separator from inside the mark. - - it('should reduce block count when Delete at start of block after heading mark', async () => { - const {container} = await render(MarkdownDocument) + describe('Delete at mark→text boundary (navigate-only in drag mode)', () => { + it('should NOT reduce row count when Delete at start of text row after mark row', async () => { + const {container} = await render(MarkdownDrag) const before = getBlocks(container).length - // block[1] is "This is a powerful..." which follows the heading mark (block[0]) - await focusAtStart(getEditableInBlock(getBlocks(container)[1])) - await userEvent.keyboard('{Delete}') - - expect(getBlocks(container)).toHaveLength(before - 1) - }) - - it('should preserve content of both blocks after merging into heading mark', async () => { - const {container} = await render(MarkdownDocument) - await focusAtStart(getEditableInBlock(getBlocks(container)[1])) await userEvent.keyboard('{Delete}') - const raw = getRawValue(container) - expect(raw).toContain('Marked Input') - expect(raw).toContain('powerful') + expect(getBlocks(container)).toHaveLength(before) }) - it('should keep focus in the heading block after Delete merge', async () => { - const {container} = await render(MarkdownDocument) - const headingBlock = getBlocks(container)[0] + it('should move focus to mark row on Delete at mark boundary', async () => { + const {container} = await render(MarkdownDrag) + const markBlock = getBlocks(container)[0] await focusAtStart(getEditableInBlock(getBlocks(container)[1])) await userEvent.keyboard('{Delete}') - expect(document.activeElement).toBe(headingBlock) + expect(document.activeElement).toBe(markBlock) }) }) }) - describe('typing in blocks', () => { - it('should update raw value when typing a character at end of block', async () => { - const {container} = await render(PlainTextBlocks) + describe('typing in rows', () => { + it('should update raw value when typing a character at end of row', async () => { + const {container} = await render(PlainTextDrag) await focusAtEnd(getEditableInBlock(getBlocks(container)[0])) await userEvent.keyboard('!') expect(getRawValue(container)).toContain('First block of plain text!') }) - it('should update raw value when deleting a character with Backspace mid-block', async () => { - const {container} = await render(PlainTextBlocks) + it('should update raw value when deleting a character with Backspace mid-row', async () => { + const {container} = await render(PlainTextDrag) await focusAtEnd(getEditableInBlock(getBlocks(container)[0])) await userEvent.keyboard('{Backspace}') @@ -781,8 +747,8 @@ describe('Feature: block keyboard navigation', () => { expect(getRawValue(container)).not.toContain('First block of plain text\n\n') }) - it('should not wipe all blocks when Ctrl+A in focused block then typing', async () => { - const {container} = await render(PlainTextBlocks) + it('should not wipe all rows when Ctrl+A in focused row then typing', async () => { + const {container} = await render(PlainTextDrag) const blocks = getBlocks(container) getEditableInBlock(blocks[1]).focus() @@ -793,18 +759,18 @@ describe('Feature: block keyboard navigation', () => { expect(getRawValue(container)).toContain('First block of plain text') }) - it('should append character after last mark when typing at end of mark block', async () => { - const {container} = await render(MarkdownDocument) + it('should append character after last mark when typing at end of mark row', async () => { + const {container} = await render(MarkdownDrag) const blocks = getBlocks(container) await focusAtEnd(getEditableInBlock(blocks[0])) await userEvent.keyboard('!') const block0Raw = getRawValue(container).split('\n\n')[0] - expect(block0Raw).toBe('# Welcome to **Marked Input**!') + expect(block0Raw).toBe('# Welcome to Draggable Blocks!') }) - it('should insert character at correct position mid-text within a mark block', async () => { - const {container} = await render(MarkdownDocument) + it('should insert character at correct position mid-text within a mark row', async () => { + const {container} = await render(MarkdownDrag) const blocks = getBlocks(container) await focusAtStart(blocks[0]) await userEvent.keyboard('{ArrowRight}{ArrowRight}') @@ -812,13 +778,13 @@ describe('Feature: block keyboard navigation', () => { await new Promise(r => setTimeout(r, 50)) const block0Raw = getRawValue(container).split('\n\n')[0] - expect(block0Raw).toBe('# WeXlcome to **Marked Input**') + expect(block0Raw).toBe('# WeXlcome to Draggable Blocks') }) }) - describe('paste in blocks', () => { - it('should update raw value when pasting text at end of a plain text block', async () => { - const {container} = await render(PlainTextBlocks) + describe('paste in rows', () => { + it('should update raw value when pasting text at end of a plain text row', async () => { + const {container} = await render(PlainTextDrag) const blocks = getBlocks(container) await focusAtEnd(getEditableInBlock(blocks[0])) dispatchPaste(blocks[0], ' pasted') @@ -827,8 +793,8 @@ describe('Feature: block keyboard navigation', () => { expect(getRawValue(container)).toContain('First block of plain text pasted') }) - it('should not affect other blocks when pasting in one block', async () => { - const {container} = await render(PlainTextBlocks) + it('should not affect other rows when pasting in one row', async () => { + const {container} = await render(PlainTextDrag) const blocks = getBlocks(container) await focusAtEnd(getEditableInBlock(blocks[0])) dispatchPaste(blocks[0], '!') @@ -840,21 +806,21 @@ describe('Feature: block keyboard navigation', () => { expect(getBlocks(container)).toHaveLength(5) }) - it('should update raw value when pasting text at end of a mark block', async () => { - const {container} = await render(MarkdownDocument) + it('should update raw value when pasting text at end of a mark row', async () => { + const {container} = await render(MarkdownDrag) const blocks = getBlocks(container) await focusAtEnd(getEditableInBlock(blocks[0])) dispatchPaste(getEditableInBlock(blocks[0]), '!') await new Promise(r => setTimeout(r, 50)) const block0Raw = getRawValue(container).split('\n\n')[0] - expect(block0Raw).toBe('# Welcome to **Marked Input**!') + expect(block0Raw).toBe('# Welcome to Draggable Blocks!') }) }) - describe('Enter mid-block split', () => { - it('should increase block count by 1', async () => { - const {container} = await render(PlainTextBlocks) + describe('Enter mid-row split', () => { + it('should increase row count by 1', async () => { + const {container} = await render(PlainTextDrag) const editable = getEditableInBlock(getBlocks(container)[0]) await userEvent.click(editable) @@ -865,8 +831,8 @@ describe('Feature: block keyboard navigation', () => { expect(getBlocks(container)).toHaveLength(6) }) - it('should put text before caret in current block', async () => { - const {container} = await render(PlainTextBlocks) + it('should put text before caret in current row', async () => { + const {container} = await render(PlainTextDrag) const editable = getEditableInBlock(getBlocks(container)[0]) await userEvent.click(editable) @@ -875,12 +841,12 @@ describe('Feature: block keyboard navigation', () => { await userEvent.keyboard('{Enter}') const raw = getRawValue(container) - const blockTexts = raw.split('\n\n') - expect(blockTexts[0]).toBe('First') + const rowTexts = raw.split('\n\n') + expect(rowTexts[0]).toBe('First') }) - it('should put text after caret in new block', async () => { - const {container} = await render(PlainTextBlocks) + it('should put text after caret in new row', async () => { + const {container} = await render(PlainTextDrag) const editable = getEditableInBlock(getBlocks(container)[0]) await userEvent.click(editable) @@ -889,18 +855,18 @@ describe('Feature: block keyboard navigation', () => { await userEvent.keyboard('{Enter}') const raw = getRawValue(container) - const blockTexts = raw.split('\n\n') - expect(blockTexts[1]).toBe(' block of plain text') + const rowTexts = raw.split('\n\n') + expect(rowTexts[1]).toBe(' block of plain text') }) - it('should not expose raw markdown syntax in block[0] after Enter with marks', async () => { - const {container} = await render(MarkdownDocument) + it('should insert new empty row after mark row when pressing Enter on mark', async () => { + const {container} = await render(MarkdownDrag) const blocks = getBlocks(container) await focusAtEnd(blocks[0]) await userEvent.keyboard('{Enter}') const raw = getRawValue(container) - expect(raw).toContain('**Marked Input**\n\n') + expect(raw).toContain('# Welcome to Draggable Blocks\n\n') }) }) }) \ No newline at end of file diff --git a/packages/vue/storybook/src/pages/Drag/Drag.stories.ts b/packages/vue/storybook/src/pages/Drag/Drag.stories.ts new file mode 100644 index 00000000..b9ab4ca6 --- /dev/null +++ b/packages/vue/storybook/src/pages/Drag/Drag.stories.ts @@ -0,0 +1,516 @@ +import type {MarkProps, Markup, Option} from '@markput/vue' +import {MarkedInput} from '@markput/vue' +import type {Meta, StoryObj} from '@storybook/vue3-vite' +import {defineComponent, h, ref} from 'vue' + +import Text from '../../shared/components/Text.vue' + +export default { + title: 'MarkedInput/Drag', + tags: ['autodocs'], + component: MarkedInput, + parameters: { + docs: { + description: { + component: + 'Drag mode: each top-level token (mark or text fragment) is its own draggable row. Adjacent marks need no separator; adjacent text rows use \\n\\n.', + }, + }, + }, +} satisfies Meta + +type Story = StoryObj> + +// ─── Shared chip component ───────────────────────────────────────────────── + +const Chip = defineComponent({ + props: {value: String, style: {type: Object}}, + setup(props) { + return () => + h( + 'span', + { + style: { + display: 'inline-block', + padding: '2px 10px', + borderRadius: 14, + background: '#e8f0fe', + color: '#1a73e8', + fontWeight: 500, + fontSize: 13, + ...(props.style as Record), + }, + }, + props.value + ) + }, +}) + +const mentionOptions: Option[] = [{markup: '@[__value__](__meta__)' as Markup}, {markup: '#[__value__]' as Markup}] + +const containerStyle = {maxWidth: '500px', margin: '0 auto', paddingLeft: '52px'} +const editorStyle = {minHeight: '120px', padding: '12px', border: '1px solid #e0e0e0', borderRadius: '8px'} + +// ─── Basic: mark rows are auto-delimited ──────────────────────────────────── + +export const BasicMentions: Story = { + render: () => + defineComponent({ + setup() { + const value = ref('@[Alice](alice)@[Bob](bob)@[Carol](carol)') + return () => + h('div', {style: containerStyle}, [ + h('p', {style: {color: '#555', fontSize: 13, marginBottom: 8}}, [ + 'Three adjacent marks — no ', + h('code', '\\n\\n'), + ' separator needed between them. Each mark is its own draggable row.', + ]), + h(MarkedInput, { + Mark: Chip, + options: mentionOptions, + value: value.value, + drag: true, + style: editorStyle, + onChange: (v: string) => { + value.value = v + }, + }), + h(Text, {label: 'Raw value:', value: value.value}), + ]) + }, + }), +} + +// ─── Mixed: text rows and mark rows ───────────────────────────────────────── + +export const MixedTokens: Story = { + render: () => + defineComponent({ + setup() { + const value = ref('Introduction\n\n@[Alice](alice)@[Bob](bob)\n\nConclusion') + return () => + h('div', {style: containerStyle}, [ + h('p', {style: {color: '#555', fontSize: 13, marginBottom: 8}}, [ + 'Text rows use ', + h('code', '\\n\\n'), + ' as separator; mark rows are auto-delimited. Drag to reorder.', + ]), + h(MarkedInput, { + Mark: Chip, + options: mentionOptions, + value: value.value, + drag: true, + style: {...editorStyle, minHeight: '160px'}, + onChange: (v: string) => { + value.value = v + }, + }), + h(Text, {label: 'Raw value:', value: value.value}), + ]) + }, + }), +} + +// ─── Tag list: marks only ─────────────────────────────────────────────────── + +const TagChip = defineComponent({ + props: {value: String}, + setup(props) { + return () => + h( + 'span', + { + style: { + display: 'inline-block', + padding: '3px 10px', + borderRadius: 4, + background: '#f1f3f4', + color: '#333', + fontSize: 13, + fontFamily: 'monospace', + }, + }, + `#${props.value}` + ) + }, +}) + +const tagOptions: Option[] = [{markup: '#[__value__]' as Markup}] + +export const TagList: Story = { + render: () => + defineComponent({ + setup() { + const value = ref('#[react]#[typescript]#[drag-and-drop]#[editor]') + return () => + h('div', {style: containerStyle}, [ + h('p', {style: {color: '#555', fontSize: 13, marginBottom: 8}}, [ + 'Tag list where every row is a mark. No separators in the value string.', + ]), + h(MarkedInput, { + Mark: TagChip, + options: tagOptions, + value: value.value, + drag: true, + style: editorStyle, + onChange: (v: string) => { + value.value = v + }, + }), + h(Text, {label: 'Raw value:', value: value.value}), + ]) + }, + }), +} + +// ─── Always-visible handles ────────────────────────────────────────────────── + +export const AlwaysShowHandle: Story = { + render: () => + defineComponent({ + setup() { + const value = ref('@[Alice](alice)@[Bob](bob)@[Carol](carol)') + return () => + h('div', {style: containerStyle}, [ + h(MarkedInput, { + Mark: Chip, + options: mentionOptions, + value: value.value, + drag: {alwaysShowHandle: true}, + style: editorStyle, + onChange: (v: string) => { + value.value = v + }, + }), + h(Text, {label: 'Raw value:', value: value.value}), + ]) + }, + }), +} + +// ─── Nested marks inside a top-level mark ──────────────────────────────────── + +const NestedChip = defineComponent({ + props: {value: String, children: {type: null}, style: {type: Object}}, + setup(props, {slots}) { + return () => + h( + 'span', + { + style: { + display: 'inline-block', + padding: '2px 10px', + borderRadius: 14, + background: '#e8f0fe', + color: '#1a73e8', + fontWeight: 500, + fontSize: 13, + ...(props.style as Record), + }, + }, + slots.default?.() ?? props.value + ) + }, +}) + +const boldOptions: Option[] = [ + { + markup: '@[__nested__](__meta__)' as Markup, + mark: (props: MarkProps) => ({...props, style: {color: '#1a73e8'}}), + }, + { + markup: '**__nested__**' as Markup, + mark: (props: MarkProps) => ({...props, style: {fontWeight: 700}}), + }, +] + +export const NestedMarks: Story = { + render: () => + defineComponent({ + setup() { + const value = ref('@[Hello **world**](demo)@[**Bold** mention](bold)\n\nPlain text row') + return () => + h('div', {style: containerStyle}, [ + h('p', {style: {color: '#555', fontSize: 13, marginBottom: 8}}, [ + 'Nested marks stay inside their parent mark — they are NOT separate rows.', + ]), + h(MarkedInput, { + Mark: NestedChip, + options: boldOptions, + value: value.value, + drag: true, + style: {...editorStyle, minHeight: '140px'}, + onChange: (v: string) => { + value.value = v + }, + }), + h(Text, {label: 'Raw value:', value: value.value}), + ]) + }, + }), +} + +// ─── Plain text rows ───────────────────────────────────────────────────────── + +export const PlainTextDrag: Story = { + render: () => + defineComponent({ + setup() { + const value = ref(`First block of plain text + +Second block of plain text + +Third block of plain text + +Fourth block of plain text + +Fifth block of plain text`) + return () => + h('div', {style: {maxWidth: '700px', margin: '0 auto', paddingLeft: '52px'}}, [ + h(MarkedInput, { + value: value.value, + drag: true, + style: { + minHeight: '200px', + padding: '12px', + border: '1px solid #e0e0e0', + borderRadius: '8px', + }, + onChange: (v: string) => { + value.value = v + }, + }), + h(Text, {label: 'Raw value:', value: value.value}), + ]) + }, + }), +} + +// ─── Markdown with block-level marks (headings + list) ─────────────────────── + +const MarkdownMark = defineComponent({ + props: {value: String, children: {type: null}, style: {type: Object}}, + setup(props, {slots}) { + return () => + h( + 'span', + {style: {...(props.style as Record), margin: '0 1px'}}, + slots.default?.() ?? props.value + ) + }, +}) + +const h1Style = {display: 'block', fontSize: '2em', fontWeight: 'bold', margin: '0.5em 0'} +const h2Style = {display: 'block', fontSize: '1.5em', fontWeight: 'bold', margin: '0.4em 0'} + +const blockLevelMarkdownOptions: Option[] = [ + { + markup: '# __nested__\n\n' as Markup, + mark: (props: MarkProps) => ({...props, style: h1Style}), + }, + { + markup: '## __nested__\n\n' as Markup, + mark: (props: MarkProps) => ({...props, style: h2Style}), + }, + { + markup: '- __nested__\n\n' as Markup, + mark: (props: MarkProps) => ({...props, style: {display: 'block', paddingLeft: '1em'}}), + }, +] as Option[] + +const mdContainerStyle = {maxWidth: '700px', margin: '0 auto', paddingLeft: '52px'} +const mdEditorStyle = {minHeight: '200px', padding: '12px', border: '1px solid #e0e0e0', borderRadius: '8px'} + +export const MarkdownDrag: Story = { + render: () => + defineComponent({ + setup() { + const value = ref(`# Welcome to Draggable Blocks + +This is the first paragraph. Hover to see the drag handle on the left. + +This is the second paragraph. Try dragging it above the first one! + +## Features + +- Drag handles appear on hover +- Drop indicators show where the block will land +- Blocks reorder by manipulating the underlying string`) + return () => + h('div', {style: mdContainerStyle}, [ + h(MarkedInput, { + Mark: MarkdownMark, + options: blockLevelMarkdownOptions, + value: value.value, + drag: true, + style: mdEditorStyle, + onChange: (v: string) => { + value.value = v + }, + }), + h(Text, {label: 'Raw value:', value: value.value}), + ]) + }, + }), +} + +export const ReadOnlyDrag: Story = { + render: () => + defineComponent({ + setup() { + const value = `# Read-Only Mode + +Drag handles are hidden in read-only mode. + +## Section A + +Content cannot be reordered. + +## Section B + +This is static content.` + return () => + h('div', {style: mdContainerStyle}, [ + h(MarkedInput, { + Mark: MarkdownMark, + options: blockLevelMarkdownOptions, + value, + readOnly: true, + drag: true, + style: mdEditorStyle, + }), + ]) + }, + }), +} + +// ─── Todo list (all marks include \n\n) ────────────────────────────────────── + +const TodoMark = defineComponent({ + props: {value: String, children: {type: null}, style: {type: Object}, todo: String}, + setup(props, {slots}) { + return () => + h('span', {style: {...(props.style as Record), margin: '0 1px'}}, [ + props.todo + ? h('span', {style: {marginRight: '6px'}}, props.todo === 'done' ? '\u2611' : '\u2610') + : null, + slots.default?.() ?? props.value, + ]) + }, +}) + +const todoOptions: Option[] = [ + { + markup: '# __nested__\n\n' as Markup, + mark: (props: MarkProps) => ({ + ...props, + style: {display: 'block', fontSize: '1.4em', fontWeight: 'bold', margin: '0.3em 0'}, + }), + }, + { + markup: '- [ ] __nested__\n\n' as Markup, + mark: (props: MarkProps) => ({...props, todo: 'pending', style: {display: 'block'}}), + }, + { + markup: '- [x] __nested__\n\n' as Markup, + mark: (props: MarkProps) => ({ + ...props, + todo: 'done', + style: {display: 'block', textDecoration: 'line-through', opacity: 0.5}, + }), + }, + { + markup: '\t- [ ] __nested__\n\n' as Markup, + mark: (props: MarkProps) => ({...props, todo: 'pending', style: {display: 'block', paddingLeft: '1.5em'}}), + }, + { + markup: '\t- [x] __nested__\n\n' as Markup, + mark: (props: MarkProps) => ({ + ...props, + todo: 'done', + style: {display: 'block', paddingLeft: '1.5em', textDecoration: 'line-through', opacity: 0.5}, + }), + }, + { + markup: '\t\t- [ ] __nested__\n\n' as Markup, + mark: (props: MarkProps) => ({...props, todo: 'pending', style: {display: 'block', paddingLeft: '3em'}}), + }, + { + markup: '\t\t- [x] __nested__\n\n' as Markup, + mark: (props: MarkProps) => ({ + ...props, + todo: 'done', + style: {display: 'block', paddingLeft: '3em', textDecoration: 'line-through', opacity: 0.5}, + }), + }, + { + markup: '> __nested__\n\n' as Markup, + mark: (props: MarkProps) => ({ + ...props, + style: {display: 'block', fontSize: '0.85em', color: '#888', fontStyle: 'italic'}, + }), + }, +] as Option[] + +const TODO_VALUE = `# \u{1F4CB} Project Launch Checklist + +- [ ] Design Phase + +\t- [ ] Create wireframes + +\t- [x] Define color palette + +\t- [ ] Design component library + +- [x] Research + +\t- [x] Analyze competitors + +\t- [x] User interviews + +\t\t- [x] Draft interview questions + +\t\t- [x] Schedule 5 sessions + +- [ ] Development + +\t- [ ] Set up CI/CD pipeline + +\t- [x] Write unit tests + +\t- [ ] API integration + +\t\t- [ ] Auth endpoints + +\t\t- [ ] Data sync + +- [ ] Launch + +\t- [ ] Final QA pass + +\t- [ ] Deploy to production + +> \u2610 = pending \u2611 = done` + +export const TodoListDrag: Story = { + render: () => + defineComponent({ + setup() { + const value = ref(TODO_VALUE) + return () => + h('div', {style: mdContainerStyle}, [ + h(MarkedInput, { + Mark: TodoMark, + options: todoOptions, + value: value.value, + drag: true, + style: {...mdEditorStyle, minHeight: '300px'}, + onChange: (v: string) => { + value.value = v + }, + }), + h(Text, {label: 'Raw value:', value: value.value}), + ]) + }, + }), +} \ No newline at end of file From 36a5c261d2b9274a39c55d069999d279d2b7fc08 Mon Sep 17 00:00:00 2001 From: Nowely Date: Thu, 12 Mar 2026 11:25:56 +0300 Subject: [PATCH 3/8] fix(drag): enhance drag operations and selection handling - Updated `addDragRow` to ensure two empty rows are created when the value is empty and a double separator is used. - Modified `ContentEditableController` to account for drag mode, ensuring all children are set to `contentEditable` in block or drag modes. - Adjusted `KeyDownController` to insert a double separator in drag mode when the caret is at the start of a row, ensuring proper row creation. - Enhanced `selectAllText` to allow native Ctrl+A behavior in both block and drag modes, preventing selection across all blocks. --- packages/common/core/src/features/blocks/dragOperations.ts | 4 +++- .../src/features/editable/ContentEditableController.ts | 7 ++++--- .../common/core/src/features/input/KeyDownController.ts | 6 +++++- packages/common/core/src/features/selection/index.ts | 4 ++-- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/common/core/src/features/blocks/dragOperations.ts b/packages/common/core/src/features/blocks/dragOperations.ts index 619d2834..c7c49fd0 100644 --- a/packages/common/core/src/features/blocks/dragOperations.ts +++ b/packages/common/core/src/features/blocks/dragOperations.ts @@ -30,8 +30,10 @@ export function addDragRow(value: string, rows: Block[], afterIndex: number): st if (rows.length === 0) return value + BLOCK_SEPARATOR // Last row: append `\n\n` — splitTokensIntoDragRows will create a trailing empty text row. + // Special case: when value is empty, appending '\n\n' creates only 1 empty row (no content + // precedes the separator). Use double separator to ensure 2 empty rows exist. if (afterIndex >= rows.length - 1) { - return value + BLOCK_SEPARATOR + return value === '' ? BLOCK_SEPARATOR + BLOCK_SEPARATOR : value + BLOCK_SEPARATOR } const curr = rows[afterIndex] diff --git a/packages/common/core/src/features/editable/ContentEditableController.ts b/packages/common/core/src/features/editable/ContentEditableController.ts index c77ea586..7aff407e 100644 --- a/packages/common/core/src/features/editable/ContentEditableController.ts +++ b/packages/common/core/src/features/editable/ContentEditableController.ts @@ -25,10 +25,11 @@ export class ContentEditableController { const value = readOnly ? 'false' : 'true' const children = container.children const isBlock = !!this.store.state.block.get() + const isDrag = !!this.store.state.drag.get() - // In non-block mode, even-indexed children are text spans (odd are marks). - // In block mode, all children are DraggableBlock divs and need contentEditable. - const step = isBlock ? 1 : 2 + // In non-block/non-drag mode, even-indexed children are text spans (odd are marks). + // In block or drag mode, all children are DraggableBlock divs and need contentEditable. + const step = isBlock || isDrag ? 1 : 2 for (let i = 0; i < children.length; i += step) { ;(children[i] as HTMLElement).contentEditable = value } diff --git a/packages/common/core/src/features/input/KeyDownController.ts b/packages/common/core/src/features/input/KeyDownController.ts index d9b8ad40..b5e39d3c 100644 --- a/packages/common/core/src/features/input/KeyDownController.ts +++ b/packages/common/core/src/features/input/KeyDownController.ts @@ -369,7 +369,11 @@ export class KeyDownController { // Text row (both block and drag modes): split at caret position const absolutePos = getCaretRawPosInBlock(blockDiv, block) - const newValue = value.slice(0, absolutePos) + BLOCK_SEPARATOR + value.slice(absolutePos) + // In drag mode, inserting '\n\n' at position 0 of a row doesn't create a new leading row + // because the leading separator is ignored by splitTokensIntoDragRows. Use a double + // separator to produce an empty text row before the existing content. + const sep = isDragMode && absolutePos === block.startPos ? BLOCK_SEPARATOR + BLOCK_SEPARATOR : BLOCK_SEPARATOR + const newValue = value.slice(0, absolutePos) + sep + value.slice(absolutePos) this.store.applyValue(newValue) // Focus the new block after re-render diff --git a/packages/common/core/src/features/selection/index.ts b/packages/common/core/src/features/selection/index.ts index cbcbfb34..cd8e1653 100644 --- a/packages/common/core/src/features/selection/index.ts +++ b/packages/common/core/src/features/selection/index.ts @@ -19,9 +19,9 @@ export function isFullSelection(store: Store): boolean { export function selectAllText(store: Store, event: KeyboardEvent): void { if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { - // In block mode, let the browser handle Ctrl+A natively so it selects + // In block/drag mode, let the browser handle Ctrl+A natively so it selects // text within the focused block only, not across all blocks. - if (store.state.block.get()) return + if (store.state.block.get() || store.state.drag.get()) return event.preventDefault() From 75386781000beb8cac0816e37fcc6f0553f136ca Mon Sep 17 00:00:00 2001 From: Nowely Date: Thu, 12 Mar 2026 12:51:16 +0300 Subject: [PATCH 4/8] refactor(blocks): consolidate block operations and remove unused features - Removed deprecated block operations: `addBlock`, `deleteBlock`, `duplicateBlock`, and `mergeBlocks`. - Updated block-related exports to streamline functionality, focusing on drag operations. - Refactored `splitTokensIntoBlocks` to `splitTokensIntoDragRows` for improved handling of drag-and-drop features. - Deleted associated tests and configurations to clean up the codebase. - Enhanced `KeyDownController` and `ContentEditableController` to better support drag mode interactions. --- packages/common/core/index.ts | 8 +- .../features/blocks/blockOperations.spec.ts | 126 ----------- .../src/features/blocks/blockOperations.ts | 72 ------ .../blocks/blockReorderIntegration.spec.ts | 119 ---------- .../common/core/src/features/blocks/config.ts | 4 - .../src/features/blocks/dragOperations.ts | 2 +- .../common/core/src/features/blocks/index.ts | 7 +- .../src/features/blocks/reorderBlocks.spec.ts | 181 --------------- .../core/src/features/blocks/reorderBlocks.ts | 87 -------- .../blocks/splitTokensIntoBlocks.spec.ts | 189 ---------------- .../features/blocks/splitTokensIntoBlocks.ts | 179 --------------- .../blocks/splitTokensIntoDragRows.ts | 90 +++++++- .../editable/ContentEditableController.ts | 7 +- .../src/features/input/KeyDownController.ts | 206 +++++++----------- .../core/src/features/selection/index.ts | 2 +- .../common/core/src/features/store/Store.ts | 1 - packages/common/core/src/shared/types.ts | 1 - packages/react/markput/index.ts | 2 +- .../markput/src/components/BlockContainer.tsx | 44 +--- .../markput/src/components/MarkedInput.tsx | 10 +- packages/vue/markput/index.ts | 2 +- .../markput/src/components/BlockContainer.vue | 38 +--- .../markput/src/components/MarkedInput.vue | 5 +- packages/vue/markput/src/types.ts | 7 +- 24 files changed, 193 insertions(+), 1196 deletions(-) delete mode 100644 packages/common/core/src/features/blocks/blockOperations.spec.ts delete mode 100644 packages/common/core/src/features/blocks/blockOperations.ts delete mode 100644 packages/common/core/src/features/blocks/blockReorderIntegration.spec.ts delete mode 100644 packages/common/core/src/features/blocks/reorderBlocks.spec.ts delete mode 100644 packages/common/core/src/features/blocks/reorderBlocks.ts delete mode 100644 packages/common/core/src/features/blocks/splitTokensIntoBlocks.spec.ts delete mode 100644 packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts diff --git a/packages/common/core/index.ts b/packages/common/core/index.ts index c9bf6c76..65abf61a 100644 --- a/packages/common/core/index.ts +++ b/packages/common/core/index.ts @@ -86,22 +86,18 @@ export {MarkHandler, type MarkOptions, type RefAccessor} from './src/features/ma // Blocks export { - splitTokensIntoBlocks, splitTokensIntoDragRows, - reorderBlocks, + splitTextByBlockSeparator, reorderDragRows, - addBlock, addDragRow, - deleteBlock, deleteDragRow, - duplicateBlock, duplicateDragRow, mergeDragRows, getMergeDragRowJoinPos, BLOCK_SEPARATOR, - getAlwaysShowHandle, getAlwaysShowHandleDrag, type Block, + type TextPart, } from './src/features/blocks' // Navigation & Input diff --git a/packages/common/core/src/features/blocks/blockOperations.spec.ts b/packages/common/core/src/features/blocks/blockOperations.spec.ts deleted file mode 100644 index 476cf947..00000000 --- a/packages/common/core/src/features/blocks/blockOperations.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -import {describe, expect, it} from 'vitest' - -import {addBlock, deleteBlock, duplicateBlock, getMergeJoinPos, mergeBlocks} from './blockOperations' -import type {Block} from './splitTokensIntoBlocks' - -function makeBlock(id: string, startPos: number, endPos: number): Block { - return {id, tokens: [], startPos, endPos} -} - -const THREE_BLOCKS: Block[] = [makeBlock('0', 0, 1), makeBlock('3', 3, 4), makeBlock('6', 6, 7)] - -describe('addBlock', () => { - it('appends block separator when blocks is empty', () => { - expect(addBlock('A', [], 0)).toBe('A\n\n') - }) - - it('appends block separator when afterIndex is last block', () => { - expect(addBlock('A\n\nB\n\nC', THREE_BLOCKS, 2)).toBe('A\n\nB\n\nC\n\n') - }) - - it('inserts block separator after middle block', () => { - expect(addBlock('A\n\nB\n\nC', THREE_BLOCKS, 0)).toBe('A\n\n\n\nB\n\nC') - }) - - it('inserts block separator after first block in two-block value', () => { - const blocks: Block[] = [makeBlock('0', 0, 1), makeBlock('3', 3, 4)] - expect(addBlock('A\n\nB', blocks, 0)).toBe('A\n\n\n\nB') - }) -}) - -describe('deleteBlock', () => { - it('returns empty string when only one block', () => { - const blocks: Block[] = [makeBlock('0', 0, 1)] - expect(deleteBlock('A', blocks, 0)).toBe('') - }) - - it('deletes first block', () => { - expect(deleteBlock('A\n\nB\n\nC', THREE_BLOCKS, 0)).toBe('B\n\nC') - }) - - it('deletes middle block', () => { - expect(deleteBlock('A\n\nB\n\nC', THREE_BLOCKS, 1)).toBe('A\n\nC') - }) - - it('deletes last block', () => { - expect(deleteBlock('A\n\nB\n\nC', THREE_BLOCKS, 2)).toBe('A\n\nB') - }) - - it('deletes from a two-block value', () => { - const blocks: Block[] = [makeBlock('0', 0, 1), makeBlock('3', 3, 4)] - expect(deleteBlock('A\n\nB', blocks, 0)).toBe('B') - expect(deleteBlock('A\n\nB', blocks, 1)).toBe('A') - }) -}) - -describe('mergeBlocks', () => { - it('removes the separator between two blocks (standard case)', () => { - // value: "A\n\nB\n\nC", blocks: [0,1], [3,4], [6,7] - expect(mergeBlocks('A\n\nB\n\nC', THREE_BLOCKS, 1)).toBe('AB\n\nC') - }) - - it('merges last block into previous', () => { - expect(mergeBlocks('A\n\nB\n\nC', THREE_BLOCKS, 2)).toBe('A\n\nBC') - }) - - it('returns value unchanged when index is 0', () => { - expect(mergeBlocks('A\n\nB\n\nC', THREE_BLOCKS, 0)).toBe('A\n\nB\n\nC') - }) - - describe('embedded separator (mark-ending-with-\\n\\n)', () => { - // When a mark token consumes the \n\n as part of its content, splitTokensIntoBlocks - // sets endPos of that block to AFTER the \n\n, making endPos === next block's startPos. - // Example: "# Heading\n\nBody" where the heading mark includes the trailing \n\n. - // block0: startPos=0, endPos=11 ("# Heading\n\n" length = 11) - // block1: startPos=11, endPos=15 ("Body") - - const HEADING_BLOCKS: Block[] = [makeBlock('0', 0, 11), makeBlock('11', 11, 15)] - - it('removes the embedded separator from the end of the previous block', () => { - // "# Heading\n\nBody" → "# HeadingBody" - expect(mergeBlocks('# Heading\n\nBody', HEADING_BLOCKS, 1)).toBe('# HeadingBody') - }) - - it('does not return the original value unchanged', () => { - const result = mergeBlocks('# Heading\n\nBody', HEADING_BLOCKS, 1) - expect(result).not.toBe('# Heading\n\nBody') - }) - }) -}) - -describe('getMergeJoinPos', () => { - it('returns endPos of previous block in the standard case', () => { - // THREE_BLOCKS: [0,1], [3,4], [6,7] — endPos=1, next startPos=3 (different) - expect(getMergeJoinPos(THREE_BLOCKS, 1)).toBe(1) - expect(getMergeJoinPos(THREE_BLOCKS, 2)).toBe(4) - }) - - it('returns endPos minus separator length when separator is embedded in previous mark', () => { - // block0 endPos=11, block1 startPos=11 → separator is embedded, join is at 11-2=9 - const HEADING_BLOCKS: Block[] = [makeBlock('0', 0, 11), makeBlock('11', 11, 15)] - expect(getMergeJoinPos(HEADING_BLOCKS, 1)).toBe(9) - }) - - it('returns 0 for index=0', () => { - expect(getMergeJoinPos(THREE_BLOCKS, 0)).toBe(0) - }) -}) - -describe('duplicateBlock', () => { - it('duplicates last block by appending', () => { - expect(duplicateBlock('A\n\nB\n\nC', THREE_BLOCKS, 2)).toBe('A\n\nB\n\nC\n\nC') - }) - - it('duplicates first block into middle', () => { - expect(duplicateBlock('A\n\nB\n\nC', THREE_BLOCKS, 0)).toBe('A\n\nA\n\nB\n\nC') - }) - - it('duplicates middle block', () => { - expect(duplicateBlock('A\n\nB\n\nC', THREE_BLOCKS, 1)).toBe('A\n\nB\n\nB\n\nC') - }) - - it('duplicates single block', () => { - const blocks: Block[] = [makeBlock('0', 0, 1)] - expect(duplicateBlock('A', blocks, 0)).toBe('A\n\nA') - }) -}) \ No newline at end of file diff --git a/packages/common/core/src/features/blocks/blockOperations.ts b/packages/common/core/src/features/blocks/blockOperations.ts deleted file mode 100644 index 0ad87c23..00000000 --- a/packages/common/core/src/features/blocks/blockOperations.ts +++ /dev/null @@ -1,72 +0,0 @@ -import {BLOCK_SEPARATOR} from './config' -import type {Block} from './splitTokensIntoBlocks' - -export function addBlock(value: string, blocks: Block[], afterIndex: number): string { - if (afterIndex >= blocks.length - 1) { - return value + BLOCK_SEPARATOR - } - - const insertPos = blocks[afterIndex + 1].startPos - return value.slice(0, insertPos) + BLOCK_SEPARATOR + value.slice(insertPos) -} - -export function deleteBlock(value: string, blocks: Block[], index: number): string { - if (blocks.length <= 1) return '' - - if (index >= blocks.length - 1) { - return value.slice(0, blocks[index - 1].endPos) - } - - return value.slice(0, blocks[index].startPos) + value.slice(blocks[index + 1].startPos) -} - -/** - * Returns the raw-value position of the join point between block[index-1] and block[index]. - * - * Normally this is `blocks[index-1].endPos` — the position right after the previous block's - * content, before the `\n\n` separator. - * - * Exception: when a mark token (e.g. a heading `# …\n\n`) consumes the `\n\n` as part of its - * content, `splitTokensIntoBlocks` sets `endPos = token.position.end` (which is AFTER the `\n\n`) - * and the next block's `startPos` equals that same value. In that case the separator is embedded - * inside the previous block's range, and the true join point is `endPos - BLOCK_SEPARATOR.length`. - */ -export function getMergeJoinPos(blocks: Block[], index: number): number { - if (index <= 0 || index >= blocks.length) return 0 - const prev = blocks[index - 1] - const curr = blocks[index] - if (prev.endPos === curr.startPos) { - return prev.endPos - BLOCK_SEPARATOR.length - } - return prev.endPos -} - -/** - * Merges block at `index` into block at `index - 1` by removing the separator between them. - * Returns the new value string with the separator removed. - * Use `getMergeJoinPos` to obtain the raw caret position after the merge. - */ -export function mergeBlocks(value: string, blocks: Block[], index: number): string { - if (index <= 0 || index >= blocks.length) return value - const prev = blocks[index - 1] - const curr = blocks[index] - if (prev.endPos === curr.startPos) { - // The \n\n separator is embedded inside the previous block's mark token. - // Remove it from the end of prev's content. - return value.slice(0, prev.endPos - BLOCK_SEPARATOR.length) + value.slice(curr.startPos) - } - // Remove everything between endPos of previous block and startPos of current block - return value.slice(0, prev.endPos) + value.slice(curr.startPos) -} - -export function duplicateBlock(value: string, blocks: Block[], index: number): string { - const block = blocks[index] - const blockText = value.substring(block.startPos, block.endPos) - - if (index >= blocks.length - 1) { - return value + BLOCK_SEPARATOR + blockText - } - - const insertPos = blocks[index + 1].startPos - return value.slice(0, insertPos) + blockText + BLOCK_SEPARATOR + value.slice(insertPos) -} \ No newline at end of file diff --git a/packages/common/core/src/features/blocks/blockReorderIntegration.spec.ts b/packages/common/core/src/features/blocks/blockReorderIntegration.spec.ts deleted file mode 100644 index d564b95e..00000000 --- a/packages/common/core/src/features/blocks/blockReorderIntegration.spec.ts +++ /dev/null @@ -1,119 +0,0 @@ -import {describe, expect, it} from 'vitest' - -import type {UseHookFactory} from '../../shared/classes/defineState' -import {Parser} from '../parsing/ParserV2/Parser' -import type {TextToken, Markup} from '../parsing/ParserV2/types' -import {getTokensByValue, parseWithParser} from '../parsing/utils/valueParser' -import {Store} from '../store/Store' -import {reorderBlocks} from './reorderBlocks' -import {splitTokensIntoBlocks} from './splitTokensIntoBlocks' - -const mockUseHook: UseHookFactory = signal => () => signal.get() - -function setupStore(value: string, markups: Markup[] = []) { - const store = new Store({createUseHook: mockUseHook}) - const parser = markups.length > 0 ? new Parser(markups) : undefined - - store.state.parser.set(parser) - store.state.value.set(value) - store.state.previousValue.set(value) - store.state.tokens.set(parseWithParser(store, value)) - - return store -} - -describe('block reorder → getTokensByValue integration', () => { - it('fix path: full re-parse + set tokens gives correct result after reorder', () => { - const original = 'aaa\n\nbbb\n\nccc' - const store = setupStore(original) - - const tokens = store.state.tokens.get() - const blocks = splitTokensIntoBlocks(tokens) - const reordered = reorderBlocks(original, blocks, 0, 2) - - expect(reordered).toBe('bbb\n\naaa\n\nccc') - - const newTokens = parseWithParser(store, reordered) - store.state.tokens.set(newTokens) - store.state.previousValue.set(reordered) - store.state.value.set(reordered) - - expect(newTokens).not.toBe(tokens) - - const fullContent = newTokens.map(t => t.content).join('') - expect(fullContent).toBe('bbb\n\naaa\n\nccc') - - const stable = getTokensByValue(store) - expect(stable.map(t => t.content).join('')).toBe('bbb\n\naaa\n\nccc') - }) - - it('fix path with parser: full re-parse gives correct tokens after reorder', () => { - const original = '# Heading\n\nParagraph' - const store = setupStore(original, ['# __nested__\n\n' as Markup]) - - const tokens = store.state.tokens.get() - const blocks = splitTokensIntoBlocks(tokens) - - expect(blocks).toHaveLength(2) - - const reordered = reorderBlocks(original, blocks, 1, 0) - expect(reordered).toBe('Paragraph\n\n# Heading') - - const newTokens = parseWithParser(store, reordered) - store.state.tokens.set(newTokens) - store.state.previousValue.set(reordered) - store.state.value.set(reordered) - - const fullContent = newTokens.map(t => t.content).join('') - expect(fullContent).toBe('Paragraph\n\n# Heading') - }) - - it('full re-parse produces correct blocks after reorder', () => { - const original = 'first\n\nsecond\n\nthird' - const store = setupStore(original) - - const tokens = store.state.tokens.get() - const blocks = splitTokensIntoBlocks(tokens) - const reordered = reorderBlocks(original, blocks, 2, 0) - - expect(reordered).toBe('third\n\nfirst\n\nsecond') - - const newTokens = parseWithParser(store, reordered) - const newBlocks = splitTokensIntoBlocks(newTokens) - - expect(newBlocks).toHaveLength(3) - expect((newBlocks[0].tokens[0] as TextToken).content).toBe('third') - expect((newBlocks[1].tokens[0] as TextToken).content).toBe('first') - expect((newBlocks[2].tokens[0] as TextToken).content).toBe('second') - }) - - it('direct token set + previousValue sync bypasses broken incremental parse', () => { - const original = 'aaa\n\nbbb\n\nccc' - const store = setupStore(original) - - const blocks = splitTokensIntoBlocks(store.state.tokens.get()) - const reordered = reorderBlocks(original, blocks, 0, 2) - expect(reordered).toBe('bbb\n\naaa\n\nccc') - - const newTokens = parseWithParser(store, reordered) - store.state.tokens.set(newTokens) - store.state.previousValue.set(reordered) - store.state.value.set(reordered) - - const result = getTokensByValue(store) - const resultContent = result.map(t => t.content).join('') - expect(resultContent).toBe('bbb\n\naaa\n\nccc') - }) - - it('without fix: incremental parse crashes on block reorder', () => { - const original = 'aaa\n\nbbb\n\nccc' - const store = setupStore(original) - - const blocks = splitTokensIntoBlocks(store.state.tokens.get()) - const reordered = reorderBlocks(original, blocks, 0, 2) - - store.state.value.set(reordered) - - expect(() => getTokensByValue(store)).toThrow(TypeError) - }) -}) \ No newline at end of file diff --git a/packages/common/core/src/features/blocks/config.ts b/packages/common/core/src/features/blocks/config.ts index fae19552..b4e5d899 100644 --- a/packages/common/core/src/features/blocks/config.ts +++ b/packages/common/core/src/features/blocks/config.ts @@ -1,9 +1,5 @@ export const BLOCK_SEPARATOR = '\n\n' -export function getAlwaysShowHandle(block: boolean | {alwaysShowHandle: boolean}): boolean { - return typeof block === 'object' && !!block.alwaysShowHandle -} - export function getAlwaysShowHandleDrag(drag: boolean | {alwaysShowHandle: boolean}): boolean { return typeof drag === 'object' && !!drag.alwaysShowHandle } \ No newline at end of file diff --git a/packages/common/core/src/features/blocks/dragOperations.ts b/packages/common/core/src/features/blocks/dragOperations.ts index c7c49fd0..0db77b7a 100644 --- a/packages/common/core/src/features/blocks/dragOperations.ts +++ b/packages/common/core/src/features/blocks/dragOperations.ts @@ -1,5 +1,5 @@ import {BLOCK_SEPARATOR} from './config' -import type {Block} from './splitTokensIntoBlocks' +import type {Block} from './splitTokensIntoDragRows' /** * In drag mode the gap between two adjacent rows can be: diff --git a/packages/common/core/src/features/blocks/index.ts b/packages/common/core/src/features/blocks/index.ts index fc947dcb..e15add30 100644 --- a/packages/common/core/src/features/blocks/index.ts +++ b/packages/common/core/src/features/blocks/index.ts @@ -1,7 +1,4 @@ -export {splitTokensIntoBlocks, type Block, splitTextByBlockSeparator, type TextPart} from './splitTokensIntoBlocks' -export {splitTokensIntoDragRows} from './splitTokensIntoDragRows' -export {reorderBlocks} from './reorderBlocks' -export {addBlock, deleteBlock, duplicateBlock, mergeBlocks, getMergeJoinPos} from './blockOperations' +export {splitTokensIntoDragRows, type Block, splitTextByBlockSeparator, type TextPart} from './splitTokensIntoDragRows' export { addDragRow, deleteDragRow, @@ -10,4 +7,4 @@ export { getMergeDragRowJoinPos, reorderDragRows, } from './dragOperations' -export {BLOCK_SEPARATOR, getAlwaysShowHandle, getAlwaysShowHandleDrag} from './config' \ No newline at end of file +export {BLOCK_SEPARATOR, getAlwaysShowHandleDrag} from './config' \ No newline at end of file diff --git a/packages/common/core/src/features/blocks/reorderBlocks.spec.ts b/packages/common/core/src/features/blocks/reorderBlocks.spec.ts deleted file mode 100644 index c99cc421..00000000 --- a/packages/common/core/src/features/blocks/reorderBlocks.spec.ts +++ /dev/null @@ -1,181 +0,0 @@ -import {describe, expect, it} from 'vitest' - -import {Parser} from '../parsing/ParserV2/Parser' -import type {TextToken} from '../parsing/ParserV2/types' -import {reorderBlocks} from './reorderBlocks' -import type {Block} from './splitTokensIntoBlocks' -import {splitTokensIntoBlocks} from './splitTokensIntoBlocks' - -function makeBlocks(...lines: string[]): {value: string; blocks: Block[]} { - const value = lines.join('\n\n') - let pos = 0 - const blocks: Block[] = lines.map(line => { - const block: Block = { - id: `block-${pos}`, - tokens: [{type: 'text', content: line, position: {start: pos, end: pos + line.length}}], - startPos: pos, - endPos: pos + line.length, - } - pos += line.length + 2 - return block - }) - return {value, blocks} -} - -describe('reorderBlocks', () => { - it('returns same value when source equals target', () => { - const {value, blocks} = makeBlocks('aaa', 'bbb', 'ccc') - expect(reorderBlocks(value, blocks, 1, 1)).toBe(value) - }) - - it('returns same value when source is adjacent to target', () => { - const {value, blocks} = makeBlocks('aaa', 'bbb', 'ccc') - expect(reorderBlocks(value, blocks, 1, 2)).toBe(value) - }) - - it('moves block forward', () => { - const {value, blocks} = makeBlocks('aaa', 'bbb', 'ccc') - const result = reorderBlocks(value, blocks, 0, 2) - expect(result).toBe('bbb\n\naaa\n\nccc') - }) - - it('moves block backward', () => { - const {value, blocks} = makeBlocks('aaa', 'bbb', 'ccc') - const result = reorderBlocks(value, blocks, 2, 0) - expect(result).toBe('ccc\n\naaa\n\nbbb') - }) - - it('moves block to end', () => { - const {value, blocks} = makeBlocks('aaa', 'bbb', 'ccc') - const result = reorderBlocks(value, blocks, 0, 3) - expect(result).toBe('bbb\n\nccc\n\naaa') - }) - - it('returns same value for single block', () => { - const {value, blocks} = makeBlocks('only one') - expect(reorderBlocks(value, blocks, 0, 0)).toBe(value) - }) - - it('returns same value for invalid indices', () => { - const {value, blocks} = makeBlocks('aaa', 'bbb') - expect(reorderBlocks(value, blocks, -1, 0)).toBe(value) - expect(reorderBlocks(value, blocks, 5, 0)).toBe(value) - expect(reorderBlocks(value, blocks, 0, -1)).toBe(value) - expect(reorderBlocks(value, blocks, 0, 5)).toBe(value) - }) - - it('returns same value for empty string', () => { - expect(reorderBlocks('', [], 0, 0)).toBe('') - }) - - it('moves first block to last position', () => { - const {value, blocks} = makeBlocks('aaa', 'bbb', 'ccc', 'ddd') - const result = reorderBlocks(value, blocks, 0, 4) - expect(result).toBe('bbb\n\nccc\n\nddd\n\naaa') - }) - - it('moves last block to first position', () => { - const {value, blocks} = makeBlocks('aaa', 'bbb', 'ccc', 'ddd') - const result = reorderBlocks(value, blocks, 3, 0) - expect(result).toBe('ddd\n\naaa\n\nbbb\n\nccc') - }) -}) - -describe('reorderBlocks round-trip (reorder → re-parse → re-split)', () => { - it('plain text: reordered value re-parses into correct blocks', () => { - const original = 'aaa\n\nbbb\n\nccc' - const parser = new Parser([]) - const tokens = parser.parse(original) - const blocks = splitTokensIntoBlocks(tokens) - - const reordered = reorderBlocks(original, blocks, 0, 2) - expect(reordered).toBe('bbb\n\naaa\n\nccc') - - const newTokens = parser.parse(reordered) - const newBlocks = splitTokensIntoBlocks(newTokens) - - expect(newBlocks).toHaveLength(3) - expect((newBlocks[0].tokens[0] as TextToken).content).toBe('bbb') - expect((newBlocks[1].tokens[0] as TextToken).content).toBe('aaa') - expect((newBlocks[2].tokens[0] as TextToken).content).toBe('ccc') - }) - - it('markdown: reordered value re-parses into correct blocks', () => { - const original = '# Heading\n\nParagraph text\n\n## Subheading' - const parser = new Parser(['# __nested__\n\n', '## __nested__\n\n']) - const tokens = parser.parse(original) - const blocks = splitTokensIntoBlocks(tokens) - - expect(blocks).toHaveLength(3) - - const reordered = reorderBlocks(original, blocks, 2, 0) - expect(reordered).toBe('## Subheading\n\n# Heading\n\nParagraph text') - - const newTokens = parser.parse(reordered) - const newBlocks = splitTokensIntoBlocks(newTokens) - - expect(newBlocks).toHaveLength(3) - }) - - it('full cycle: split → reorder → re-parse → re-split produces consistent blocks', () => { - const lines = ['First line', 'Second line', 'Third line', 'Fourth line'] - const original = lines.join('\n\n') - const parser = new Parser([]) - - const tokens1 = parser.parse(original) - const blocks1 = splitTokensIntoBlocks(tokens1) - expect(blocks1).toHaveLength(4) - - const reordered = reorderBlocks(original, blocks1, 0, 4) - expect(reordered).toBe('Second line\n\nThird line\n\nFourth line\n\nFirst line') - - const tokens2 = parser.parse(reordered) - const blocks2 = splitTokensIntoBlocks(tokens2) - expect(blocks2).toHaveLength(4) - expect((blocks2[0].tokens[0] as TextToken).content).toBe('Second line') - expect((blocks2[3].tokens[0] as TextToken).content).toBe('First line') - - const reordered2 = reorderBlocks(reordered, blocks2, 3, 0) - expect(reordered2).toBe(original) - }) - - it('preserves double newline between adjacent blocks after reorder', () => { - const original = 'aaa\n\nbbb\n\nccc' - const parser = new Parser([]) - const tokens = parser.parse(original) - const blocks = splitTokensIntoBlocks(tokens) - - const reordered = reorderBlocks(original, blocks, 0, 2) - expect(reordered).toBe('bbb\n\naaa\n\nccc') - expect(reordered.split('\n\n')).toHaveLength(3) - }) - - it('trailing double newline creates empty block and is preserved after reorder', () => { - const original = 'aaa\n\nbbb\n\nccc\n\n' - const parser = new Parser([]) - const tokens = parser.parse(original) - const blocks = splitTokensIntoBlocks(tokens) - - expect(blocks).toHaveLength(4) - expect(blocks[2].endPos).toBe(13) - - const reordered = reorderBlocks(original, blocks, 0, 2) - expect(reordered).toBe('bbb\n\naaa\n\nccc\n\n') - }) - - it('handles unicode content correctly', () => { - const original = '你好\n\n世界\n\n🎉' - const parser = new Parser([]) - const tokens = parser.parse(original) - const blocks = splitTokensIntoBlocks(tokens) - - const reordered = reorderBlocks(original, blocks, 2, 0) - expect(reordered).toBe('🎉\n\n你好\n\n世界') - - const newTokens = parser.parse(reordered) - const newBlocks = splitTokensIntoBlocks(newTokens) - expect((newBlocks[0].tokens[0] as TextToken).content).toBe('🎉') - expect((newBlocks[1].tokens[0] as TextToken).content).toBe('你好') - expect((newBlocks[2].tokens[0] as TextToken).content).toBe('世界') - }) -}) \ No newline at end of file diff --git a/packages/common/core/src/features/blocks/reorderBlocks.ts b/packages/common/core/src/features/blocks/reorderBlocks.ts deleted file mode 100644 index 1beab00f..00000000 --- a/packages/common/core/src/features/blocks/reorderBlocks.ts +++ /dev/null @@ -1,87 +0,0 @@ -import {BLOCK_SEPARATOR} from './config' -import type {Block} from './splitTokensIntoBlocks' - -interface OrderedBlock { - index: number - text: string - separatorAfter: string -} - -export function reorderBlocks(value: string, blocks: Block[], sourceIndex: number, targetIndex: number): string { - if (sourceIndex === targetIndex || sourceIndex === targetIndex - 1) return value - if (blocks.length < 2) return value - if (sourceIndex < 0 || sourceIndex >= blocks.length) return value - if (targetIndex < 0 || targetIndex > blocks.length) return value - - const orderedBlocks = extractBlocksWithSeparators(value, blocks) - const newOrder = reorder(orderedBlocks, sourceIndex, targetIndex) - - return reassembleBlocks(newOrder) -} - -function extractBlocksWithSeparators(value: string, blocks: Block[]): OrderedBlock[] { - return blocks.map((block, i) => { - const text = value.substring(block.startPos, block.endPos) - let separatorAfter = '' - - if (i < blocks.length - 1) { - const nextBlock = blocks[i + 1] - separatorAfter = value.substring(block.endPos, nextBlock.startPos) - } - - return { - index: i, - text, - separatorAfter, - } - }) -} - -function reassembleBlocks(orderedBlocks: OrderedBlock[]): string { - const result: string[] = [] - - for (let i = 0; i < orderedBlocks.length; i++) { - const block = orderedBlocks[i] - const isLast = i === orderedBlocks.length - 1 - - let text = block.text - while (text.endsWith('\n')) { - text = text.slice(0, -1) - } - - result.push(text) - - if (!isLast) { - result.push(block.separatorAfter || BLOCK_SEPARATOR) - } - } - - return result.join('') -} - -function reorder(items: OrderedBlock[], sourceIndex: number, targetIndex: number): OrderedBlock[] { - const result = [...items] - const [moved] = result.splice(sourceIndex, 1) - - const insertAt = targetIndex > sourceIndex ? targetIndex - 1 : targetIndex - result.splice(insertAt, 0, moved) - - redistributeSeparators(result, items) - - return result -} - -function redistributeSeparators(blocks: OrderedBlock[], originalBlocks: OrderedBlock[]): void { - for (let i = 0; i < blocks.length - 1; i++) { - const currentOriginalIndex = blocks[i].index - const nextOriginalIndex = blocks[i + 1].index - - if (Math.abs(currentOriginalIndex - nextOriginalIndex) === 1) { - const earlierIndex = Math.min(currentOriginalIndex, nextOriginalIndex) - const originalSeparator = originalBlocks[earlierIndex].separatorAfter - blocks[i].separatorAfter = originalSeparator.length > 0 ? originalSeparator : BLOCK_SEPARATOR - } else { - blocks[i].separatorAfter = BLOCK_SEPARATOR - } - } -} \ No newline at end of file diff --git a/packages/common/core/src/features/blocks/splitTokensIntoBlocks.spec.ts b/packages/common/core/src/features/blocks/splitTokensIntoBlocks.spec.ts deleted file mode 100644 index b13508d0..00000000 --- a/packages/common/core/src/features/blocks/splitTokensIntoBlocks.spec.ts +++ /dev/null @@ -1,189 +0,0 @@ -import {describe, expect, it} from 'vitest' - -import type {Token, TextToken, MarkToken} from '../parsing/ParserV2/types' -import {splitTokensIntoBlocks} from './splitTokensIntoBlocks' - -function text(content: string, start: number): TextToken { - return {type: 'text', content, position: {start, end: start + content.length}} -} - -function mark(content: string, start: number, value = ''): MarkToken { - return { - type: 'mark', - content, - position: {start, end: start + content.length}, - value, - children: [], - descriptor: { - markup: '' as any, - index: 0, - segments: [], - gapTypes: [], - hasNested: false, - hasTwoValues: false, - segmentGlobalIndices: [], - }, - } -} - -describe('splitTokensIntoBlocks', () => { - it('returns empty array for empty tokens', () => { - expect(splitTokensIntoBlocks([])).toEqual([]) - }) - - it('groups single-line text into one block', () => { - const tokens: Token[] = [text('hello world', 0)] - const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(1) - expect(blocks[0].tokens).toHaveLength(1) - expect((blocks[0].tokens[0] as TextToken).content).toBe('hello world') - }) - - it('keeps single newlines as content within a block', () => { - const tokens: Token[] = [text('line one\nline two\nline three', 0)] - const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(1) - expect((blocks[0].tokens[0] as TextToken).content).toBe('line one\nline two\nline three') - }) - - it('splits text by double newlines (empty lines) into separate blocks', () => { - const tokens: Token[] = [text('line one\n\nline two\n\nline three', 0)] - const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(3) - expect((blocks[0].tokens[0] as TextToken).content).toBe('line one') - expect((blocks[1].tokens[0] as TextToken).content).toBe('line two') - expect((blocks[2].tokens[0] as TextToken).content).toBe('line three') - }) - - it('handles block-level marks ending with double newline', () => { - const heading = mark('# Hello\n\n', 0, 'Hello') - heading.content = '# Hello\n\n' - const tokens: Token[] = [heading, text('paragraph text', 10)] - const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(2) - expect(blocks[0].tokens[0]).toBe(heading) - expect((blocks[1].tokens[0] as TextToken).content).toBe('paragraph text') - }) - - it('keeps inline marks in the same block as surrounding text', () => { - const tokens: Token[] = [text('hello ', 0), mark('@[world](meta)', 6, 'world'), text(' end', 20)] - const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(1) - expect(blocks[0].tokens).toHaveLength(3) - }) - - it('assigns correct positions to blocks with double newlines', () => { - const tokens: Token[] = [text('aaa\n\nbbb\n\nccc', 0)] - const blocks = splitTokensIntoBlocks(tokens) - expect(blocks[0].startPos).toBe(0) - expect(blocks[0].endPos).toBe(3) - expect(blocks[1].startPos).toBe(5) - expect(blocks[1].endPos).toBe(8) - expect(blocks[2].startPos).toBe(10) - expect(blocks[2].endPos).toBe(13) - }) - - it('handles consecutive double newlines as separate empty blocks', () => { - const tokens: Token[] = [text('aaa\n\n\n\nbbb', 0)] - const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(3) - expect((blocks[0].tokens[0] as TextToken).content).toBe('aaa') - expect(blocks[1].tokens).toHaveLength(0) - expect((blocks[2].tokens[0] as TextToken).content).toBe('bbb') - }) - - it('creates empty block from trailing double newline', () => { - const tokens: Token[] = [text('aaa\n\nbbb\n\n', 0)] - const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(3) - expect((blocks[0].tokens[0] as TextToken).content).toBe('aaa') - expect((blocks[1].tokens[0] as TextToken).content).toBe('bbb') - expect(blocks[2].tokens).toHaveLength(0) - expect(blocks[2].startPos).toBe(blocks[2].endPos) - }) - - it('single trailing newline stays as content in last block', () => { - const tokens: Token[] = [text('aaa\n\nbbb\n', 0)] - const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(2) - expect((blocks[0].tokens[0] as TextToken).content).toBe('aaa') - expect((blocks[1].tokens[0] as TextToken).content).toBe('bbb\n') - }) - - it('generates unique block ids based on start position', () => { - const tokens: Token[] = [text('aaa\n\nbbb\n\nccc', 0)] - const blocks = splitTokensIntoBlocks(tokens) - const ids = blocks.map(b => b.id) - expect(new Set(ids).size).toBe(ids.length) - }) - - it('handles empty text token', () => { - const tokens: Token[] = [text('', 0)] - const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(0) - }) - - it('handles text with only double newlines', () => { - const tokens: Token[] = [text('\n\n\n\n', 0)] - const blocks = splitTokensIntoBlocks(tokens) - // Two separators produce three empty blocks (leading + middle + trailing). - expect(blocks).toHaveLength(3) - blocks.forEach(b => { - expect(b.tokens).toHaveLength(0) - expect(b.startPos).toBe(b.endPos) - }) - }) - - it('handles \\r\\n\\r\\n line endings (Windows double newline)', () => { - const tokens: Token[] = [text('line one\r\n\r\nline two\r\n\r\nline three', 0)] - const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(3) - expect((blocks[0].tokens[0] as TextToken).content).toBe('line one') - expect((blocks[1].tokens[0] as TextToken).content).toBe('line two') - expect((blocks[2].tokens[0] as TextToken).content).toBe('line three') - }) - - it('handles single \\r\\n as content within block', () => { - const tokens: Token[] = [text('line one\r\nline two', 0)] - const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(1) - expect((blocks[0].tokens[0] as TextToken).content).toBe('line one\nline two') - }) - - it('handles mixed single and double line endings', () => { - const tokens: Token[] = [text('line one\nline two\r\n\r\nline three', 0)] - const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(2) - expect((blocks[0].tokens[0] as TextToken).content).toBe('line one\nline two') - expect((blocks[1].tokens[0] as TextToken).content).toBe('line three') - }) - - it('handles standalone \\r as newline content', () => { - const tokens: Token[] = [text('line one\rline two', 0)] - const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(1) - expect((blocks[0].tokens[0] as TextToken).content).toBe('line one\nline two') - }) - - it('handles unicode and emoji content with double newlines', () => { - const tokens: Token[] = [text('你好世界\n\n🎉 emoji\n\nØÆÅ', 0)] - const blocks = splitTokensIntoBlocks(tokens) - expect(blocks).toHaveLength(3) - expect((blocks[0].tokens[0] as TextToken).content).toBe('你好世界') - expect((blocks[1].tokens[0] as TextToken).content).toBe('🎉 emoji') - expect((blocks[2].tokens[0] as TextToken).content).toBe('ØÆÅ') - }) - - it('handles very long text without performance issues', () => { - const lines = Array(1000).fill('line') - const longText = lines.join('\n\n') - const tokens: Token[] = [text(longText, 0)] - - const start = performance.now() - const blocks = splitTokensIntoBlocks(tokens) - const duration = performance.now() - start - - expect(blocks).toHaveLength(1000) - expect(duration).toBeLessThan(100) - }) -}) \ No newline at end of file diff --git a/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts b/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts deleted file mode 100644 index 5cc391d1..00000000 --- a/packages/common/core/src/features/blocks/splitTokensIntoBlocks.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type {Token, TextToken} from '../parsing/ParserV2/types' -import {BLOCK_SEPARATOR} from './config' - -export interface Block { - id: string - tokens: Token[] - startPos: number - endPos: number -} - -let blockIdCounter = 0 - -function generateBlockId(startPos: number): string { - return `block-${blockIdCounter++}-${startPos}` -} - -export function resetBlockIdCounter(): void { - blockIdCounter = 0 -} - -export function splitTokensIntoBlocks(tokens: Token[]): Block[] { - if (tokens.length === 0) return [] - - resetBlockIdCounter() - - const blocks: Block[] = [] - let currentTokens: Token[] = [] - let blockStart = -1 - let blockStartFromText = false - - const flushBlock = (endPos: number, canCreateEmpty = false) => { - const isEmpty = currentTokens.length === 0 - if (blockStart === -1 && isEmpty && !canCreateEmpty) return - if (isEmpty && !canCreateEmpty) return - const startPos = blockStart === -1 ? endPos : blockStart - blocks.push({ - id: generateBlockId(startPos), - tokens: [...currentTokens], - startPos, - endPos: isEmpty ? startPos : endPos, - }) - currentTokens = [] - blockStart = -1 - blockStartFromText = false - } - - for (const token of tokens) { - if (token.type === 'mark') { - const endsWithBlockSeparator = token.content.endsWith(BLOCK_SEPARATOR) - - if (endsWithBlockSeparator) { - flushBlock(token.position.start) - - blocks.push({ - id: generateBlockId(token.position.start), - tokens: [token], - startPos: token.position.start, - endPos: token.position.end, - }) - blockStart = token.position.end - blockStartFromText = false - } else { - if (blockStart === -1) blockStart = token.position.start - currentTokens.push(token) - } - continue - } - - if (token.type !== 'text') continue - - const textToken = token - const parts = splitTextByBlockSeparator(textToken) - - for (let i = 0; i < parts.length; i++) { - const part = parts[i] - - if (part.isBlockSeparator) { - flushBlock(part.position.start, true) - blockStart = part.position.end - blockStartFromText = true - continue - } - - if (part.content.length === 0) continue - - if (blockStart === -1) blockStart = part.position.start - currentTokens.push({ - type: 'text', - content: part.content, - position: part.position, - }) - } - } - - const lastPos = currentTokens.length > 0 ? currentTokens[currentTokens.length - 1].position.end : blockStart - if (blockStart !== -1 || currentTokens.length > 0) { - flushBlock(lastPos === -1 ? 0 : lastPos, currentTokens.length > 0 || blockStartFromText) - } - - return blocks -} - -export interface TextPart { - content: string - position: {start: number; end: number} - isBlockSeparator: boolean -} - -export function splitTextByBlockSeparator(token: TextToken): TextPart[] { - const parts: TextPart[] = [] - const {content, position} = token - - let offset = position.start - const chars: string[] = [] - - const flushText = () => { - if (chars.length > 0) { - const text = chars.join('') - parts.push({ - content: text, - position: {start: offset, end: offset + text.length}, - isBlockSeparator: false, - }) - offset += text.length - chars.length = 0 - } - } - - for (let i = 0; i < content.length; i++) { - const char = content[i] - - if (char === '\n') { - if (i + 1 < content.length && content[i + 1] === '\n') { - flushText() - parts.push({ - content: BLOCK_SEPARATOR, - position: {start: offset, end: offset + 2}, - isBlockSeparator: true, - }) - offset += 2 - i++ - } else { - chars.push(char) - offset++ - } - } else if (char === '\r') { - if (i + 1 < content.length && content[i + 1] === '\n') { - if ( - i + 2 < content.length && - content[i + 2] === '\r' && - i + 3 < content.length && - content[i + 3] === '\n' - ) { - flushText() - parts.push({ - content: BLOCK_SEPARATOR, - position: {start: offset, end: offset + 4}, - isBlockSeparator: true, - }) - offset += 4 - i += 3 - } else { - chars.push('\n') - offset += 2 - i++ - } - } else { - chars.push('\n') - offset++ - } - } else { - chars.push(char) - } - } - - flushText() - - return parts -} \ No newline at end of file diff --git a/packages/common/core/src/features/blocks/splitTokensIntoDragRows.ts b/packages/common/core/src/features/blocks/splitTokensIntoDragRows.ts index e40a784c..4a17f652 100644 --- a/packages/common/core/src/features/blocks/splitTokensIntoDragRows.ts +++ b/packages/common/core/src/features/blocks/splitTokensIntoDragRows.ts @@ -1,6 +1,90 @@ import type {Token, TextToken} from '../parsing/ParserV2/types' -import {splitTextByBlockSeparator} from './splitTokensIntoBlocks' -import type {Block} from './splitTokensIntoBlocks' +import {BLOCK_SEPARATOR} from './config' + +export interface Block { + id: string + tokens: Token[] + startPos: number + endPos: number +} + +export interface TextPart { + content: string + position: {start: number; end: number} + isBlockSeparator: boolean +} + +export function splitTextByBlockSeparator(token: TextToken): TextPart[] { + const parts: TextPart[] = [] + const {content, position} = token + + let offset = position.start + const chars: string[] = [] + + const flushText = () => { + if (chars.length > 0) { + const text = chars.join('') + parts.push({ + content: text, + position: {start: offset, end: offset + text.length}, + isBlockSeparator: false, + }) + offset += text.length + chars.length = 0 + } + } + + for (let i = 0; i < content.length; i++) { + const char = content[i] + + if (char === '\n') { + if (i + 1 < content.length && content[i + 1] === '\n') { + flushText() + parts.push({ + content: BLOCK_SEPARATOR, + position: {start: offset, end: offset + 2}, + isBlockSeparator: true, + }) + offset += 2 + i++ + } else { + chars.push(char) + offset++ + } + } else if (char === '\r') { + if (i + 1 < content.length && content[i + 1] === '\n') { + if ( + i + 2 < content.length && + content[i + 2] === '\r' && + i + 3 < content.length && + content[i + 3] === '\n' + ) { + flushText() + parts.push({ + content: BLOCK_SEPARATOR, + position: {start: offset, end: offset + 4}, + isBlockSeparator: true, + }) + offset += 4 + i += 3 + } else { + chars.push('\n') + offset += 2 + i++ + } + } else { + chars.push('\n') + offset++ + } + } else { + chars.push(char) + } + } + + flushText() + + return parts +} let dragRowIdCounter = 0 @@ -15,7 +99,7 @@ export function resetDragRowIdCounter(): void { /** * Splits a flat token list into drag rows where each top-level token = one row. * - * Unlike `splitTokensIntoBlocks` (which groups multiple tokens per block, separated by `\n\n`), + * Unlike `splitTokensIntoBlocks` (which grouped multiple tokens per block separated by `\n\n`), * this function makes each individual token its own row: * - MarkToken → one row (auto-delimited by mark syntax, no `\n\n` needed between marks) * - TextToken → split by `\n\n` → one row per non-separator fragment diff --git a/packages/common/core/src/features/editable/ContentEditableController.ts b/packages/common/core/src/features/editable/ContentEditableController.ts index 7aff407e..381ed503 100644 --- a/packages/common/core/src/features/editable/ContentEditableController.ts +++ b/packages/common/core/src/features/editable/ContentEditableController.ts @@ -24,12 +24,11 @@ export class ContentEditableController { const readOnly = this.store.state.readOnly.get() const value = readOnly ? 'false' : 'true' const children = container.children - const isBlock = !!this.store.state.block.get() const isDrag = !!this.store.state.drag.get() - // In non-block/non-drag mode, even-indexed children are text spans (odd are marks). - // In block or drag mode, all children are DraggableBlock divs and need contentEditable. - const step = isBlock || isDrag ? 1 : 2 + // In non-drag mode, even-indexed children are text spans (odd are marks). + // In drag mode, all children are DraggableBlock divs and need contentEditable. + const step = isDrag ? 1 : 2 for (let i = 0; i < children.length; i += step) { ;(children[i] as HTMLElement).contentEditable = value } diff --git a/packages/common/core/src/features/input/KeyDownController.ts b/packages/common/core/src/features/input/KeyDownController.ts index b5e39d3c..888d5969 100644 --- a/packages/common/core/src/features/input/KeyDownController.ts +++ b/packages/common/core/src/features/input/KeyDownController.ts @@ -1,10 +1,8 @@ import type {NodeProxy} from '../../shared/classes/NodeProxy' import {KEYBOARD} from '../../shared/constants' -import {deleteBlock, getMergeJoinPos, mergeBlocks} from '../blocks/blockOperations' import {BLOCK_SEPARATOR} from '../blocks/config' import {addDragRow, getMergeDragRowJoinPos, mergeDragRows, isTextRow} from '../blocks/dragOperations' -import {splitTokensIntoBlocks, type Block} from '../blocks/splitTokensIntoBlocks' -import {splitTokensIntoDragRows} from '../blocks/splitTokensIntoDragRows' +import {splitTokensIntoDragRows, type Block} from '../blocks/splitTokensIntoDragRows' import {Caret} from '../caret' import {shiftFocusNext, shiftFocusPrev} from '../navigation' import type {MarkToken} from '../parsing/ParserV2/types' @@ -67,12 +65,11 @@ export class KeyDownController { #handleDelete(event: KeyboardEvent) { const {focus} = this.store.nodes - const isBlockMode = !!this.store.state.block.get() const isDragMode = !!this.store.state.drag.get() - // Mark/span deletion only applies in non-block/non-drag mode. - // In block/drag mode the focus target is a block div, not a span/mark. - if (!isBlockMode && !isDragMode && (event.key === KEYBOARD.DELETE || event.key === KEYBOARD.BACKSPACE)) { + // Mark/span deletion only applies in non-drag mode. + // In drag mode the focus target is a block div, not a span/mark. + if (!isDragMode && (event.key === KEYBOARD.DELETE || event.key === KEYBOARD.BACKSPACE)) { if (focus.isMark) { if (focus.isEditable) { if (event.key === KEYBOARD.BACKSPACE && !focus.isCaretAtBeginning) return @@ -100,7 +97,7 @@ export class KeyDownController { } } - if (!isBlockMode && !isDragMode) return + if (!isDragMode) return const container = this.store.refs.container if (!container) return @@ -112,7 +109,7 @@ export class KeyDownController { if (blockIndex === -1) return const tokens = this.store.state.tokens.get() - const blocks = isDragMode ? splitTokensIntoDragRows(tokens) : splitTokensIntoBlocks(tokens) + const blocks = splitTokensIntoDragRows(tokens) if (blockIndex >= blocks.length) return const block = blocks[blockIndex] @@ -127,15 +124,17 @@ export class KeyDownController { const blockText = block.tokens.map(t => ('content' in t ? (t as {content: string}).content : '')).join('') if (blockText === '') { event.preventDefault() - const newValue = isDragMode - ? blocks.length <= 1 + const newValue = + blocks.length <= 1 ? '' : (() => { - const b = blocks - if (blockIndex >= b.length - 1) return value.slice(0, b[blockIndex - 1].endPos) - return value.slice(0, b[blockIndex].startPos) + value.slice(b[blockIndex + 1].startPos) + if (blockIndex >= blocks.length - 1) + return value.slice(0, blocks[blockIndex - 1].endPos) + return ( + value.slice(0, blocks[blockIndex].startPos) + + value.slice(blocks[blockIndex + 1].startPos) + ) })() - : deleteBlock(value, blocks, blockIndex) this.store.applyValue(newValue) queueMicrotask(() => { const newDivs = container.children @@ -151,52 +150,34 @@ export class KeyDownController { // Non-empty block at position 0: merge with previous block if (caretAtStart && blockIndex > 0) { - if (isDragMode) { - const prevBlock = blocks[blockIndex - 1] - const currBlock = blocks[blockIndex] - const gap = currBlock.startPos - prevBlock.endPos - if (isTextRow(prevBlock) && isTextRow(currBlock) && gap === 2) { - // Text-text merge: remove the \n\n separator - event.preventDefault() - const joinPos = getMergeDragRowJoinPos(blocks, blockIndex) - const newValue = mergeDragRows(value, blocks, blockIndex) - this.store.applyValue(newValue) - queueMicrotask(() => { - const newDivs = container.children - const target = newDivs[blockIndex - 1] as HTMLElement | undefined - if (target) { - target.focus() - const updatedBlocks = splitTokensIntoDragRows(this.store.state.tokens.get()) - const updatedBlock = updatedBlocks[blockIndex - 1] - if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos) - } - }) - return - } - // Previous row is a mark (or gap=0): navigate only + const prevBlock = blocks[blockIndex - 1] + const currBlock = blocks[blockIndex] + const gap = currBlock.startPos - prevBlock.endPos + if (isTextRow(prevBlock) && isTextRow(currBlock) && gap === 2) { + // Text-text merge: remove the \n\n separator event.preventDefault() + const joinPos = getMergeDragRowJoinPos(blocks, blockIndex) + const newValue = mergeDragRows(value, blocks, blockIndex) + this.store.applyValue(newValue) queueMicrotask(() => { - const target = blockDivs[blockIndex - 1] as HTMLElement | undefined + const newDivs = container.children + const target = newDivs[blockIndex - 1] as HTMLElement | undefined if (target) { target.focus() - Caret.setCaretToEnd(target) + const updatedBlocks = splitTokensIntoDragRows(this.store.state.tokens.get()) + const updatedBlock = updatedBlocks[blockIndex - 1] + if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos) } }) return } - + // Previous row is a mark (or gap=0): navigate only event.preventDefault() - const joinPos = getMergeJoinPos(blocks, blockIndex) - const newValue = mergeBlocks(value, blocks, blockIndex) - this.store.applyValue(newValue) queueMicrotask(() => { - const newDivs = container.children - const target = newDivs[blockIndex - 1] as HTMLElement | undefined + const target = blockDivs[blockIndex - 1] as HTMLElement | undefined if (target) { target.focus() - const updatedBlocks = splitTokensIntoBlocks(this.store.state.tokens.get()) - const updatedBlock = updatedBlocks[blockIndex - 1] - if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos) + Caret.setCaretToEnd(target) } }) return @@ -211,51 +192,33 @@ export class KeyDownController { // Caret at start of non-first block: merge current block into previous (like Backspace at start) if (caretAtStart && blockIndex > 0) { - if (isDragMode) { - const prevBlock = blocks[blockIndex - 1] - const currBlock = blocks[blockIndex] - const gap = currBlock.startPos - prevBlock.endPos - if (isTextRow(prevBlock) && isTextRow(currBlock) && gap === 2) { - event.preventDefault() - const joinPos = getMergeDragRowJoinPos(blocks, blockIndex) - const newValue = mergeDragRows(value, blocks, blockIndex) - this.store.applyValue(newValue) - queueMicrotask(() => { - const newDivs = container.children - const target = newDivs[blockIndex - 1] as HTMLElement | undefined - if (target) { - target.focus() - const updatedBlocks = splitTokensIntoDragRows(this.store.state.tokens.get()) - const updatedBlock = updatedBlocks[blockIndex - 1] - if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos) - } - }) - return - } - // Previous row is a mark: navigate only + const prevBlock = blocks[blockIndex - 1] + const currBlock = blocks[blockIndex] + const gap = currBlock.startPos - prevBlock.endPos + if (isTextRow(prevBlock) && isTextRow(currBlock) && gap === 2) { event.preventDefault() + const joinPos = getMergeDragRowJoinPos(blocks, blockIndex) + const newValue = mergeDragRows(value, blocks, blockIndex) + this.store.applyValue(newValue) queueMicrotask(() => { - const target = blockDivs[blockIndex - 1] as HTMLElement | undefined + const newDivs = container.children + const target = newDivs[blockIndex - 1] as HTMLElement | undefined if (target) { target.focus() - Caret.setCaretToEnd(target) + const updatedBlocks = splitTokensIntoDragRows(this.store.state.tokens.get()) + const updatedBlock = updatedBlocks[blockIndex - 1] + if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos) } }) return } - + // Previous row is a mark: navigate only event.preventDefault() - const joinPos = getMergeJoinPos(blocks, blockIndex) - const newValue = mergeBlocks(value, blocks, blockIndex) - this.store.applyValue(newValue) queueMicrotask(() => { - const newDivs = container.children - const target = newDivs[blockIndex - 1] as HTMLElement | undefined + const target = blockDivs[blockIndex - 1] as HTMLElement | undefined if (target) { target.focus() - const updatedBlocks = splitTokensIntoBlocks(this.store.state.tokens.get()) - const updatedBlock = updatedBlocks[blockIndex - 1] - if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos) + Caret.setCaretToEnd(target) } }) return @@ -263,51 +226,33 @@ export class KeyDownController { // Caret at end of non-last block: merge next block into current if (caretAtEnd && blockIndex < blocks.length - 1) { - if (isDragMode) { - const currBlock = blocks[blockIndex] - const nextBlock = blocks[blockIndex + 1] - const gap = nextBlock.startPos - currBlock.endPos - if (isTextRow(currBlock) && isTextRow(nextBlock) && gap === 2) { - event.preventDefault() - const joinPos = currBlock.endPos - const newValue = mergeDragRows(value, blocks, blockIndex + 1) - this.store.applyValue(newValue) - queueMicrotask(() => { - const newDivs = container.children - const target = newDivs[blockIndex] as HTMLElement | undefined - if (target) { - target.focus() - const updatedBlocks = splitTokensIntoDragRows(this.store.state.tokens.get()) - const updatedBlock = updatedBlocks[blockIndex] - if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos) - } - }) - return - } - // Next row is a mark: navigate only + const currBlock = blocks[blockIndex] + const nextBlock = blocks[blockIndex + 1] + const gap = nextBlock.startPos - currBlock.endPos + if (isTextRow(currBlock) && isTextRow(nextBlock) && gap === 2) { event.preventDefault() + const joinPos = currBlock.endPos + const newValue = mergeDragRows(value, blocks, blockIndex + 1) + this.store.applyValue(newValue) queueMicrotask(() => { - const target = blockDivs[blockIndex + 1] as HTMLElement | undefined + const newDivs = container.children + const target = newDivs[blockIndex] as HTMLElement | undefined if (target) { target.focus() - Caret.trySetIndex(target, 0) + const updatedBlocks = splitTokensIntoDragRows(this.store.state.tokens.get()) + const updatedBlock = updatedBlocks[blockIndex] + if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos) } }) return } - + // Next row is a mark: navigate only event.preventDefault() - const joinPos = block.endPos - const newValue = mergeBlocks(value, blocks, blockIndex + 1) - this.store.applyValue(newValue) queueMicrotask(() => { - const newDivs = container.children - const target = newDivs[blockIndex] as HTMLElement | undefined + const target = blockDivs[blockIndex + 1] as HTMLElement | undefined if (target) { target.focus() - const updatedBlocks = splitTokensIntoBlocks(this.store.state.tokens.get()) - const updatedBlock = updatedBlocks[blockIndex] - if (updatedBlock) setCaretAtRawPos(target, updatedBlock, joinPos) + Caret.trySetIndex(target, 0) } }) return @@ -316,9 +261,8 @@ export class KeyDownController { } #handleEnter(event: KeyboardEvent) { - const isBlockMode = !!this.store.state.block.get() const isDragMode = !!this.store.state.drag.get() - if (!isBlockMode && !isDragMode) return + if (!isDragMode) return if (event.key !== KEYBOARD.ENTER) return if (event.shiftKey) return @@ -342,16 +286,15 @@ export class KeyDownController { if (blockIndex === -1) return const tokens = this.store.state.tokens.get() - const blocks = isDragMode ? splitTokensIntoDragRows(tokens) : splitTokensIntoBlocks(tokens) - if (blockIndex >= blocks.length) return - + const blocks = splitTokensIntoDragRows(tokens) const block = blocks[blockIndex] + if (!block) return const blockDiv = blockDivs[blockIndex] as HTMLElement - const value = this.store.state.value.get() ?? this.store.state.previousValue.get() ?? '' + const value = this.store.state.value.get() ?? '' if (!this.store.state.onChange.get()) return - if (isDragMode && !isTextRow(block)) { + if (!isTextRow(block)) { // Mark row in drag mode: add a new empty text row after this row const newValue = addDragRow(value, blocks, blockIndex) this.store.applyValue(newValue) @@ -367,12 +310,12 @@ export class KeyDownController { return } - // Text row (both block and drag modes): split at caret position + // Text row: split at caret position const absolutePos = getCaretRawPosInBlock(blockDiv, block) - // In drag mode, inserting '\n\n' at position 0 of a row doesn't create a new leading row + // Inserting '\n\n' at position 0 of a row doesn't create a new leading row // because the leading separator is ignored by splitTokensIntoDragRows. Use a double // separator to produce an empty text row before the existing content. - const sep = isDragMode && absolutePos === block.startPos ? BLOCK_SEPARATOR + BLOCK_SEPARATOR : BLOCK_SEPARATOR + const sep = absolutePos === block.startPos ? BLOCK_SEPARATOR + BLOCK_SEPARATOR : BLOCK_SEPARATOR const newValue = value.slice(0, absolutePos) + sep + value.slice(absolutePos) this.store.applyValue(newValue) @@ -389,7 +332,7 @@ export class KeyDownController { } #handleArrowUpDown(event: KeyboardEvent) { - if (!this.store.state.block.get() && !this.store.state.drag.get()) return + if (!this.store.state.drag.get()) return const container = this.store.refs.container if (!container) return @@ -462,7 +405,7 @@ export function handleBeforeInput(store: Store, event: InputEvent): void { // In block/drag mode the focus target is a block div, not a text span. // Block-level keys (Enter, Backspace, Delete) are handled by KeyDownController. // Text insertions and in-block deletions need special handling to update state. - if (store.state.block.get() || store.state.drag.get()) { + if (store.state.drag.get()) { handleBlockBeforeInput(store, event) return } @@ -584,7 +527,7 @@ export function replaceAllContentWith(store: Store, newContent: string): void { } /** - * Handles `beforeinput` events when the editor is in block mode. + * Handles `beforeinput` events when the editor is in drag mode. * Intercepts text insertion and in-block deletion to update the raw value via * `store.applyValue`, since `applySpanInput` is designed for span-level editing only. * Block-level operations (Enter, Backspace/Delete at boundaries) are handled by @@ -603,8 +546,7 @@ function handleBlockBeforeInput(store: Store, event: InputEvent): void { const blockDiv = blockDivs[blockIndex] const tokens = store.state.tokens.get() - const isDragMode = !!store.state.drag.get() - const blocks = isDragMode ? splitTokensIntoDragRows(tokens) : splitTokensIntoBlocks(tokens) + const blocks = splitTokensIntoDragRows(tokens) if (blockIndex >= blocks.length) return const block = blocks[blockIndex] @@ -616,9 +558,7 @@ function handleBlockBeforeInput(store: Store, event: InputEvent): void { if (!target) return target.focus() // Use updated tokens (post-applyValue) for correct token positions - const updatedBlocks = isDragMode - ? splitTokensIntoDragRows(store.state.tokens.get()) - : splitTokensIntoBlocks(store.state.tokens.get()) + const updatedBlocks = splitTokensIntoDragRows(store.state.tokens.get()) const updatedBlock = updatedBlocks[blockIndex] if (updatedBlock) setCaretAtRawPos(target, updatedBlock, newRawPos) }) diff --git a/packages/common/core/src/features/selection/index.ts b/packages/common/core/src/features/selection/index.ts index cd8e1653..ecfbf23a 100644 --- a/packages/common/core/src/features/selection/index.ts +++ b/packages/common/core/src/features/selection/index.ts @@ -21,7 +21,7 @@ export function selectAllText(store: Store, event: KeyboardEvent): void { if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { // In block/drag mode, let the browser handle Ctrl+A natively so it selects // text within the focused block only, not across all blocks. - if (store.state.block.get() || store.state.drag.get()) return + if (store.state.drag.get()) return event.preventDefault() diff --git a/packages/common/core/src/features/store/Store.ts b/packages/common/core/src/features/store/Store.ts index ac45f966..0a0d43d7 100644 --- a/packages/common/core/src/features/store/Store.ts +++ b/packages/common/core/src/features/store/Store.ts @@ -72,7 +72,6 @@ export class Store { style: undefined, slots: undefined, slotProps: undefined, - block: false, drag: false, }, options.createUseHook diff --git a/packages/common/core/src/shared/types.ts b/packages/common/core/src/shared/types.ts index 4bb1cc14..c3320f50 100644 --- a/packages/common/core/src/shared/types.ts +++ b/packages/common/core/src/shared/types.ts @@ -65,7 +65,6 @@ export interface MarkputState { style: StyleProperties | undefined slots: CoreSlots | undefined slotProps: CoreSlotProps | undefined - block: boolean | {alwaysShowHandle: boolean} drag: boolean | {alwaysShowHandle: boolean} } diff --git a/packages/react/markput/index.ts b/packages/react/markput/index.ts index 44863726..5d30ce54 100644 --- a/packages/react/markput/index.ts +++ b/packages/react/markput/index.ts @@ -7,5 +7,5 @@ export type {OverlayHandler} from './src/lib/hooks/useOverlay' export type {Option, MarkProps, OverlayProps} from './src/types' // Re-export from core -export {denote, annotate, MarkHandler, splitTokensIntoBlocks, reorderBlocks} from '@markput/core' +export {denote, annotate, MarkHandler} from '@markput/core' export type {MarkputHandler, Markup, Token, TextToken, MarkToken, Block} from '@markput/core' \ No newline at end of file diff --git a/packages/react/markput/src/components/BlockContainer.tsx b/packages/react/markput/src/components/BlockContainer.tsx index 61aa34d1..7f51a3bc 100644 --- a/packages/react/markput/src/components/BlockContainer.tsx +++ b/packages/react/markput/src/components/BlockContainer.tsx @@ -2,17 +2,11 @@ import { cx, resolveSlot, resolveSlotProps, - splitTokensIntoBlocks, splitTokensIntoDragRows, - reorderBlocks, reorderDragRows, - addBlock, addDragRow, - deleteBlock, deleteDragRow, - duplicateBlock, duplicateDragRow, - getAlwaysShowHandle, getAlwaysShowHandleDrag, type Block, } from '@markput/core' @@ -161,10 +155,8 @@ export const BlockContainer = memo(() => { const className = store.state.className.use() const style = store.state.style.use() const readOnly = store.state.readOnly.use() - const block = store.state.block.use() const drag = store.state.drag.use() - const isDragMode = !!drag - const alwaysShowHandle = isDragMode ? getAlwaysShowHandleDrag(drag) : getAlwaysShowHandle(block) + const alwaysShowHandle = getAlwaysShowHandleDrag(drag) const value = store.state.value.use() const onChange = store.state.onChange.use() const key = store.key @@ -176,31 +168,25 @@ export const BlockContainer = memo(() => { const containerProps = useMemo(() => resolveSlotProps('container', slotProps), [slotProps]) const blocks = useMemo(() => { - const result = isDragMode ? splitTokensIntoDragRows(tokens) : splitTokensIntoBlocks(tokens) + const result = splitTokensIntoDragRows(tokens) return result.length > 0 ? result : [EMPTY_BLOCK] - }, [tokens, isDragMode]) + }, [tokens]) const blocksRef = useRef(blocks) blocksRef.current = blocks const handleReorder = useCallback( (sourceIndex: number, targetIndex: number) => { if (value == null || !onChange) return - const newValue = isDragMode - ? reorderDragRows(value, blocksRef.current, sourceIndex, targetIndex) - : reorderBlocks(value, blocksRef.current, sourceIndex, targetIndex) + const newValue = reorderDragRows(value, blocksRef.current, sourceIndex, targetIndex) if (newValue !== value) store.applyValue(newValue) }, - [store, value, onChange, isDragMode] + [store, value, onChange] ) const handleAdd = useCallback( (afterIndex: number) => { if (value == null || !onChange) return - store.applyValue( - isDragMode - ? addDragRow(value, blocksRef.current, afterIndex) - : addBlock(value, blocksRef.current, afterIndex) - ) + store.applyValue(addDragRow(value, blocksRef.current, afterIndex)) queueMicrotask(() => { const container = store.refs.container if (!container) return @@ -209,31 +195,23 @@ export const BlockContainer = memo(() => { target?.focus() }) }, - [store, value, onChange, isDragMode] + [store, value, onChange] ) const handleDelete = useCallback( (index: number) => { if (value == null || !onChange) return - store.applyValue( - isDragMode - ? deleteDragRow(value, blocksRef.current, index) - : deleteBlock(value, blocksRef.current, index) - ) + store.applyValue(deleteDragRow(value, blocksRef.current, index)) }, - [store, value, onChange, isDragMode] + [store, value, onChange] ) const handleDuplicate = useCallback( (index: number) => { if (value == null || !onChange) return - store.applyValue( - isDragMode - ? duplicateDragRow(value, blocksRef.current, index) - : duplicateBlock(value, blocksRef.current, index) - ) + store.applyValue(duplicateDragRow(value, blocksRef.current, index)) }, - [store, value, onChange, isDragMode] + [store, value, onChange] ) const handleRequestMenu = useCallback((index: number, rect: DOMRect) => { diff --git a/packages/react/markput/src/components/MarkedInput.tsx b/packages/react/markput/src/components/MarkedInput.tsx index 871a93cd..b0f4d3f6 100644 --- a/packages/react/markput/src/components/MarkedInput.tsx +++ b/packages/react/markput/src/components/MarkedInput.tsx @@ -70,12 +70,8 @@ export interface MarkedInputProps void /** Read-only mode */ readOnly?: boolean - /** Enable Notion-like draggable blocks with drag handles for reordering. - * Pass an object to configure block behavior, e.g. `{ alwaysShowHandle: true }` for mobile. - */ - block?: boolean | {alwaysShowHandle: boolean} /** Enable drag mode: each individual token (mark or text) becomes its own draggable row. - * Unlike `block`, rows are token-granular — one mark per row, one text fragment per row. + * One mark per row, one text fragment per row. * Adjacent marks need no separator; adjacent text rows are separated by `\n\n`. */ drag?: boolean | {alwaysShowHandle: boolean} @@ -90,7 +86,6 @@ export function MarkedInput diff --git a/packages/vue/markput/index.ts b/packages/vue/markput/index.ts index 9ac56873..17feadf6 100644 --- a/packages/vue/markput/index.ts +++ b/packages/vue/markput/index.ts @@ -6,5 +6,5 @@ export type {OverlayHandler} from './src/lib/hooks/useOverlay' export type {MarkedInputProps, Option, MarkProps, OverlayProps} from './src/types' // Re-export from core -export {denote, annotate, MarkHandler, splitTokensIntoBlocks, reorderBlocks} from '@markput/core' +export {denote, annotate, MarkHandler} from '@markput/core' export type {MarkputHandler, Markup, Token, TextToken, MarkToken, Block} from '@markput/core' \ No newline at end of file diff --git a/packages/vue/markput/src/components/BlockContainer.vue b/packages/vue/markput/src/components/BlockContainer.vue index 6935c60f..f99382b6 100644 --- a/packages/vue/markput/src/components/BlockContainer.vue +++ b/packages/vue/markput/src/components/BlockContainer.vue @@ -2,17 +2,11 @@ import { resolveSlot, resolveSlotProps, - splitTokensIntoBlocks, splitTokensIntoDragRows, - reorderBlocks, reorderDragRows, - addBlock, addDragRow, - deleteBlock, deleteDragRow, - duplicateBlock, duplicateDragRow, - getAlwaysShowHandle, getAlwaysShowHandleDrag, } from '@markput/core' import type {Component} from 'vue' @@ -29,12 +23,8 @@ const slotProps = store.state.slotProps.use() const className = store.state.className.use() const style = store.state.style.use() const readOnly = store.state.readOnly.use() -const block = store.state.block.use() const drag = store.state.drag.use() -const isDragMode = computed(() => !!drag.value) -const alwaysShowHandle = computed(() => - isDragMode.value ? getAlwaysShowHandleDrag(drag.value) : getAlwaysShowHandle(block.value) -) +const alwaysShowHandle = computed(() => getAlwaysShowHandleDrag(drag.value)) const value = store.state.value.use() const onChange = store.state.onChange.use() const key = store.key @@ -42,43 +32,27 @@ const key = store.key const containerTag = computed(() => resolveSlot('container', slots.value)) const containerProps = computed(() => resolveSlotProps('container', slotProps.value)) -const blocks = computed(() => - isDragMode.value ? splitTokensIntoDragRows(tokens.value) : splitTokensIntoBlocks(tokens.value) -) +const blocks = computed(() => splitTokensIntoDragRows(tokens.value)) function handleReorder(sourceIndex: number, targetIndex: number) { if (!value.value || !onChange.value) return - const newValue = isDragMode.value - ? reorderDragRows(value.value, blocks.value, sourceIndex, targetIndex) - : reorderBlocks(value.value, blocks.value, sourceIndex, targetIndex) + const newValue = reorderDragRows(value.value, blocks.value, sourceIndex, targetIndex) if (newValue !== value.value) store.applyValue(newValue) } function handleAdd(afterIndex: number) { if (!value.value || !onChange.value) return - store.applyValue( - isDragMode.value - ? addDragRow(value.value, blocks.value, afterIndex) - : addBlock(value.value, blocks.value, afterIndex) - ) + store.applyValue(addDragRow(value.value, blocks.value, afterIndex)) } function handleDelete(index: number) { if (!value.value || !onChange.value) return - store.applyValue( - isDragMode.value - ? deleteDragRow(value.value, blocks.value, index) - : deleteBlock(value.value, blocks.value, index) - ) + store.applyValue(deleteDragRow(value.value, blocks.value, index)) } function handleDuplicate(index: number) { if (!value.value || !onChange.value) return - store.applyValue( - isDragMode.value - ? duplicateDragRow(value.value, blocks.value, index) - : duplicateBlock(value.value, blocks.value, index) - ) + store.applyValue(duplicateDragRow(value.value, blocks.value, index)) } diff --git a/packages/vue/markput/src/components/MarkedInput.vue b/packages/vue/markput/src/components/MarkedInput.vue index b73092ff..30fb1c6b 100644 --- a/packages/vue/markput/src/components/MarkedInput.vue +++ b/packages/vue/markput/src/components/MarkedInput.vue @@ -17,11 +17,10 @@ const props = withDefaults(defineProps(), { options: () => DEFAULT_OPTIONS, showOverlayOn: 'change', readOnly: false, - block: false, drag: false, }) -const ContainerImpl = computed(() => (props.block || props.drag ? BlockContainer : Container)) +const ContainerImpl = computed(() => (props.drag ? BlockContainer : Container)) const emit = defineEmits<{ change: [value: string] @@ -43,7 +42,6 @@ function syncProps() { defaultValue: props.defaultValue, onChange: (v: string) => emit('change', v), readOnly: props.readOnly, - block: props.block, drag: props.drag, options: props.options, showOverlayOn: props.showOverlayOn, @@ -71,7 +69,6 @@ watch( props.style, props.slots, props.slotProps, - props.block, props.drag, ], syncProps diff --git a/packages/vue/markput/src/types.ts b/packages/vue/markput/src/types.ts index 4bf984d5..2303ae97 100644 --- a/packages/vue/markput/src/types.ts +++ b/packages/vue/markput/src/types.ts @@ -32,12 +32,9 @@ export interface MarkedInputProps Date: Fri, 13 Mar 2026 21:02:25 +0300 Subject: [PATCH 5/8] refactor(drag): clean up Drag stories and remove unused components - Removed unused story components and consolidated the Drag stories for React and Vue. - Updated the Markdown story to enhance clarity and maintain consistency across implementations. - Streamlined the code by eliminating redundant components and improving overall readability. - Ensured that the drag-and-drop functionality remains intact while simplifying the story structure. --- .../storybook/src/pages/Drag/Drag.stories.tsx | 283 +-------------- .../storybook/src/pages/Drag/Drag.stories.ts | 327 ------------------ 2 files changed, 2 insertions(+), 608 deletions(-) diff --git a/packages/react/storybook/src/pages/Drag/Drag.stories.tsx b/packages/react/storybook/src/pages/Drag/Drag.stories.tsx index aabcd678..eef4b468 100644 --- a/packages/react/storybook/src/pages/Drag/Drag.stories.tsx +++ b/packages/react/storybook/src/pages/Drag/Drag.stories.tsx @@ -24,226 +24,6 @@ export default { type Story = StoryObj> -// ─── Shared mark component ──────────────────────────────────────────────────── - -const Chip = ({value, style}: {value?: string; style?: CSSProperties}) => ( - - {value} - -) - -const mentionOptions: Option[] = [{markup: '@[__value__](__meta__)'}, {markup: '#[__value__]'}] - -// ─── Basic: mark rows are auto-delimited ────────────────────────────────────── - -export const BasicMentions: Story = { - render: () => { - const [value, setValue] = useState('@[Alice](alice)@[Bob](bob)@[Carol](carol)') - - return ( -
-

- Three adjacent marks — no \n\n separator needed between them. Each mark is its own - draggable row. -

- - -
- ) - }, -} - -// ─── Mixed: text rows and mark rows ─────────────────────────────────────────── - -export const MixedTokens: Story = { - render: () => { - const [value, setValue] = useState('Introduction\n\n@[Alice](alice)@[Bob](bob)\n\nConclusion') - - return ( -
-

- Text rows use \n\n as separator; mark rows are auto-delimited. Drag to reorder. -

- - -
- ) - }, -} - -// ─── Tag list: marks only ───────────────────────────────────────────────────── - -const TagChip = ({value}: {value?: string}) => ( - - #{value} - -) - -const tagOptions: Option[] = [{markup: '#[__value__]'}] - -export const TagList: Story = { - render: () => { - const [value, setValue] = useState('#[react]#[typescript]#[drag-and-drop]#[editor]') - - return ( -
-

- Tag list where every row is a mark. No separators in the value string. -

- - -
- ) - }, -} - -// ─── Always-visible handles ─────────────────────────────────────────────────── - -export const AlwaysShowHandle: Story = { - render: () => { - const [value, setValue] = useState('@[Alice](alice)@[Bob](bob)@[Carol](carol)') - - return ( -
- - -
- ) - }, -} - -// ─── Nested marks inside a top-level mark ──────────────────────────────────── - -interface BoldMarkProps extends MarkProps { - style?: CSSProperties -} - -// For nested marks the outer mark receives `children` (rendered inner marks), -// not a plain `value` string — so render both. -const NestedChip = ({value, children, style}: BoldMarkProps) => ( - - {children ?? value} - -) - -// Use __nested__ so the parser supports marks inside the outer @[...](meta). -// Use the function form of `mark` so base props (including `children`) are preserved. -const boldOptions: Option[] = [ - {markup: '@[__nested__](__meta__)', mark: (props: MarkProps) => ({...props, style: {color: '#1a73e8'}})}, - {markup: '**__nested__**', mark: (props: MarkProps) => ({...props, style: {fontWeight: 700}})}, -] - -export const NestedMarks: Story = { - render: () => { - const [value, setValue] = useState('@[Hello **world**](demo)@[**Bold** mention](bold)\n\nPlain text row') - - return ( -
-

- Nested marks stay inside their parent mark — they are NOT separate rows. -

- - -
- ) - }, -} - -// ─── Plain text rows ────────────────────────────────────────────────────────── - -export const PlainTextDrag: Story = { - render: () => { - const [value, setValue] = useState(`First block of plain text - -Second block of plain text - -Third block of plain text - -Fourth block of plain text - -Fifth block of plain text`) - - return ( -
- - -
- ) - }, -} - // ─── Markdown with block-level marks (headings + list) ──────────────────────── const MarkdownMark = ({ @@ -256,37 +36,7 @@ const MarkdownMark = ({ style?: React.CSSProperties }) => {children || value} -export const MarkdownDrag: Story = { - render: () => { - const [value, setValue] = useState(`# Welcome to Draggable Blocks - -This is the first paragraph. Hover to see the drag handle on the left. - -This is the second paragraph. Try dragging it above the first one! - -## Features - -- Drag handles appear on hover -- Drop indicators show where the block will land -- Blocks reorder by manipulating the underlying string`) - - return ( -
- - -
- ) - }, -} - -export const MarkdownDocumentDrag: Story = { +export const Markdown: Story = { render: () => { const [value, setValue] = useState(COMPLEX_MARKDOWN) @@ -306,35 +56,6 @@ export const MarkdownDocumentDrag: Story = { }, } -export const ReadOnlyDrag: Story = { - render: () => { - const value = `# Read-Only Mode - -Drag handles are hidden in read-only mode. - -## Section A - -Content cannot be reordered. - -## Section B - -This is static content.` - - return ( -
- -
- ) - }, -} - // ─── Todo list (all marks include \n\n) ─────────────────────────────────────── interface TodoMarkProps extends MarkProps { @@ -460,7 +181,7 @@ const TODO_VALUE = `# \u{1F4CB} Project Launch Checklist > \u2610 = pending \u2611 = done` -export const TodoListDrag: Story = { +export const TodoList: Story = { render: () => { const [value, setValue] = useState(TODO_VALUE) diff --git a/packages/vue/storybook/src/pages/Drag/Drag.stories.ts b/packages/vue/storybook/src/pages/Drag/Drag.stories.ts index b9ab4ca6..8c982e00 100644 --- a/packages/vue/storybook/src/pages/Drag/Drag.stories.ts +++ b/packages/vue/storybook/src/pages/Drag/Drag.stories.ts @@ -21,303 +21,6 @@ export default { type Story = StoryObj> -// ─── Shared chip component ───────────────────────────────────────────────── - -const Chip = defineComponent({ - props: {value: String, style: {type: Object}}, - setup(props) { - return () => - h( - 'span', - { - style: { - display: 'inline-block', - padding: '2px 10px', - borderRadius: 14, - background: '#e8f0fe', - color: '#1a73e8', - fontWeight: 500, - fontSize: 13, - ...(props.style as Record), - }, - }, - props.value - ) - }, -}) - -const mentionOptions: Option[] = [{markup: '@[__value__](__meta__)' as Markup}, {markup: '#[__value__]' as Markup}] - -const containerStyle = {maxWidth: '500px', margin: '0 auto', paddingLeft: '52px'} -const editorStyle = {minHeight: '120px', padding: '12px', border: '1px solid #e0e0e0', borderRadius: '8px'} - -// ─── Basic: mark rows are auto-delimited ──────────────────────────────────── - -export const BasicMentions: Story = { - render: () => - defineComponent({ - setup() { - const value = ref('@[Alice](alice)@[Bob](bob)@[Carol](carol)') - return () => - h('div', {style: containerStyle}, [ - h('p', {style: {color: '#555', fontSize: 13, marginBottom: 8}}, [ - 'Three adjacent marks — no ', - h('code', '\\n\\n'), - ' separator needed between them. Each mark is its own draggable row.', - ]), - h(MarkedInput, { - Mark: Chip, - options: mentionOptions, - value: value.value, - drag: true, - style: editorStyle, - onChange: (v: string) => { - value.value = v - }, - }), - h(Text, {label: 'Raw value:', value: value.value}), - ]) - }, - }), -} - -// ─── Mixed: text rows and mark rows ───────────────────────────────────────── - -export const MixedTokens: Story = { - render: () => - defineComponent({ - setup() { - const value = ref('Introduction\n\n@[Alice](alice)@[Bob](bob)\n\nConclusion') - return () => - h('div', {style: containerStyle}, [ - h('p', {style: {color: '#555', fontSize: 13, marginBottom: 8}}, [ - 'Text rows use ', - h('code', '\\n\\n'), - ' as separator; mark rows are auto-delimited. Drag to reorder.', - ]), - h(MarkedInput, { - Mark: Chip, - options: mentionOptions, - value: value.value, - drag: true, - style: {...editorStyle, minHeight: '160px'}, - onChange: (v: string) => { - value.value = v - }, - }), - h(Text, {label: 'Raw value:', value: value.value}), - ]) - }, - }), -} - -// ─── Tag list: marks only ─────────────────────────────────────────────────── - -const TagChip = defineComponent({ - props: {value: String}, - setup(props) { - return () => - h( - 'span', - { - style: { - display: 'inline-block', - padding: '3px 10px', - borderRadius: 4, - background: '#f1f3f4', - color: '#333', - fontSize: 13, - fontFamily: 'monospace', - }, - }, - `#${props.value}` - ) - }, -}) - -const tagOptions: Option[] = [{markup: '#[__value__]' as Markup}] - -export const TagList: Story = { - render: () => - defineComponent({ - setup() { - const value = ref('#[react]#[typescript]#[drag-and-drop]#[editor]') - return () => - h('div', {style: containerStyle}, [ - h('p', {style: {color: '#555', fontSize: 13, marginBottom: 8}}, [ - 'Tag list where every row is a mark. No separators in the value string.', - ]), - h(MarkedInput, { - Mark: TagChip, - options: tagOptions, - value: value.value, - drag: true, - style: editorStyle, - onChange: (v: string) => { - value.value = v - }, - }), - h(Text, {label: 'Raw value:', value: value.value}), - ]) - }, - }), -} - -// ─── Always-visible handles ────────────────────────────────────────────────── - -export const AlwaysShowHandle: Story = { - render: () => - defineComponent({ - setup() { - const value = ref('@[Alice](alice)@[Bob](bob)@[Carol](carol)') - return () => - h('div', {style: containerStyle}, [ - h(MarkedInput, { - Mark: Chip, - options: mentionOptions, - value: value.value, - drag: {alwaysShowHandle: true}, - style: editorStyle, - onChange: (v: string) => { - value.value = v - }, - }), - h(Text, {label: 'Raw value:', value: value.value}), - ]) - }, - }), -} - -// ─── Nested marks inside a top-level mark ──────────────────────────────────── - -const NestedChip = defineComponent({ - props: {value: String, children: {type: null}, style: {type: Object}}, - setup(props, {slots}) { - return () => - h( - 'span', - { - style: { - display: 'inline-block', - padding: '2px 10px', - borderRadius: 14, - background: '#e8f0fe', - color: '#1a73e8', - fontWeight: 500, - fontSize: 13, - ...(props.style as Record), - }, - }, - slots.default?.() ?? props.value - ) - }, -}) - -const boldOptions: Option[] = [ - { - markup: '@[__nested__](__meta__)' as Markup, - mark: (props: MarkProps) => ({...props, style: {color: '#1a73e8'}}), - }, - { - markup: '**__nested__**' as Markup, - mark: (props: MarkProps) => ({...props, style: {fontWeight: 700}}), - }, -] - -export const NestedMarks: Story = { - render: () => - defineComponent({ - setup() { - const value = ref('@[Hello **world**](demo)@[**Bold** mention](bold)\n\nPlain text row') - return () => - h('div', {style: containerStyle}, [ - h('p', {style: {color: '#555', fontSize: 13, marginBottom: 8}}, [ - 'Nested marks stay inside their parent mark — they are NOT separate rows.', - ]), - h(MarkedInput, { - Mark: NestedChip, - options: boldOptions, - value: value.value, - drag: true, - style: {...editorStyle, minHeight: '140px'}, - onChange: (v: string) => { - value.value = v - }, - }), - h(Text, {label: 'Raw value:', value: value.value}), - ]) - }, - }), -} - -// ─── Plain text rows ───────────────────────────────────────────────────────── - -export const PlainTextDrag: Story = { - render: () => - defineComponent({ - setup() { - const value = ref(`First block of plain text - -Second block of plain text - -Third block of plain text - -Fourth block of plain text - -Fifth block of plain text`) - return () => - h('div', {style: {maxWidth: '700px', margin: '0 auto', paddingLeft: '52px'}}, [ - h(MarkedInput, { - value: value.value, - drag: true, - style: { - minHeight: '200px', - padding: '12px', - border: '1px solid #e0e0e0', - borderRadius: '8px', - }, - onChange: (v: string) => { - value.value = v - }, - }), - h(Text, {label: 'Raw value:', value: value.value}), - ]) - }, - }), -} - -// ─── Markdown with block-level marks (headings + list) ─────────────────────── - -const MarkdownMark = defineComponent({ - props: {value: String, children: {type: null}, style: {type: Object}}, - setup(props, {slots}) { - return () => - h( - 'span', - {style: {...(props.style as Record), margin: '0 1px'}}, - slots.default?.() ?? props.value - ) - }, -}) - -const h1Style = {display: 'block', fontSize: '2em', fontWeight: 'bold', margin: '0.5em 0'} -const h2Style = {display: 'block', fontSize: '1.5em', fontWeight: 'bold', margin: '0.4em 0'} - -const blockLevelMarkdownOptions: Option[] = [ - { - markup: '# __nested__\n\n' as Markup, - mark: (props: MarkProps) => ({...props, style: h1Style}), - }, - { - markup: '## __nested__\n\n' as Markup, - mark: (props: MarkProps) => ({...props, style: h2Style}), - }, - { - markup: '- __nested__\n\n' as Markup, - mark: (props: MarkProps) => ({...props, style: {display: 'block', paddingLeft: '1em'}}), - }, -] as Option[] - const mdContainerStyle = {maxWidth: '700px', margin: '0 auto', paddingLeft: '52px'} const mdEditorStyle = {minHeight: '200px', padding: '12px', border: '1px solid #e0e0e0', borderRadius: '8px'} @@ -354,36 +57,6 @@ This is the second paragraph. Try dragging it above the first one! }), } -export const ReadOnlyDrag: Story = { - render: () => - defineComponent({ - setup() { - const value = `# Read-Only Mode - -Drag handles are hidden in read-only mode. - -## Section A - -Content cannot be reordered. - -## Section B - -This is static content.` - return () => - h('div', {style: mdContainerStyle}, [ - h(MarkedInput, { - Mark: MarkdownMark, - options: blockLevelMarkdownOptions, - value, - readOnly: true, - drag: true, - style: mdEditorStyle, - }), - ]) - }, - }), -} - // ─── Todo list (all marks include \n\n) ────────────────────────────────────── const TodoMark = defineComponent({ From 16a1f9d5ff9bc74bf93dbc50acdc31bbd60d709f Mon Sep 17 00:00:00 2001 From: Nowely Date: Fri, 13 Mar 2026 21:28:53 +0300 Subject: [PATCH 6/8] refactor(drag): update Drag stories to use new DRAG_MARKDOWN constant - Replaced the previous COMPLEX_MARKDOWN with DRAG_MARKDOWN in both React and Vue Drag stories for consistency. - Updated markdown options to streamline the rendering of drag-and-drop functionality. - Enhanced the clarity of the Markdown story by providing a more relevant example for drag mode. --- .../storybook/src/pages/Drag/Drag.stories.tsx | 8 +- .../storybook/src/shared/lib/sampleTexts.ts | 28 +++++ .../storybook/src/pages/Drag/Drag.stories.ts | 118 ++++++++++++++++-- 3 files changed, 139 insertions(+), 15 deletions(-) diff --git a/packages/react/storybook/src/pages/Drag/Drag.stories.tsx b/packages/react/storybook/src/pages/Drag/Drag.stories.tsx index eef4b468..75ac833f 100644 --- a/packages/react/storybook/src/pages/Drag/Drag.stories.tsx +++ b/packages/react/storybook/src/pages/Drag/Drag.stories.tsx @@ -5,8 +5,8 @@ import type {CSSProperties, ReactNode} from 'react' import {useState} from 'react' import {Text} from '../../shared/components/Text' -import {COMPLEX_MARKDOWN} from '../../shared/lib/sampleTexts' -import {blockLevelMarkdownOptions} from '../Nested/MarkdownOptions' +import {DRAG_MARKDOWN} from '../../shared/lib/sampleTexts' +import {markdownOptions} from '../Nested/MarkdownOptions' export default { title: 'MarkedInput/Drag', @@ -38,13 +38,13 @@ const MarkdownMark = ({ export const Markdown: Story = { render: () => { - const [value, setValue] = useState(COMPLEX_MARKDOWN) + const [value, setValue] = useState(DRAG_MARKDOWN) return (
> const mdContainerStyle = {maxWidth: '700px', margin: '0 auto', paddingLeft: '52px'} const mdEditorStyle = {minHeight: '200px', padding: '12px', border: '1px solid #e0e0e0', borderRadius: '8px'} -export const MarkdownDrag: Story = { - render: () => - defineComponent({ - setup() { - const value = ref(`# Welcome to Draggable Blocks +const MarkdownMark = defineComponent({ + props: {value: String, meta: String, nested: String, style: {type: Object}}, + setup(props, {slots}) { + return () => + h( + 'span', + {style: {...(props.style as Record), margin: '0 1px'}}, + slots.default?.() ?? props.value + ) + }, +}) + +const markdownOptions: Option[] = [ + { + markup: '# __nested__\n\n' as Markup, + mark: (props: MarkProps) => ({ + ...props, + style: {display: 'block', fontSize: '2em', fontWeight: 'bold', margin: '0.5em 0'}, + }), + }, + { + markup: '## __nested__\n\n' as Markup, + mark: (props: MarkProps) => ({ + ...props, + style: {display: 'block', fontSize: '1.5em', fontWeight: 'bold', margin: '0.4em 0'}, + }), + }, + { + markup: '### __nested__\n\n' as Markup, + mark: (props: MarkProps) => ({ + ...props, + style: {display: 'block', fontSize: '1.17em', fontWeight: 'bold', margin: '0.83em 0'}, + }), + }, + { + markup: '- __nested__\n\n' as Markup, + mark: (props: MarkProps) => ({...props, style: {display: 'block', paddingLeft: '1em'}}), + }, + {markup: '**__nested__**' as Markup, mark: (props: MarkProps) => ({...props, style: {fontWeight: 'bold'}})}, + {markup: '*__nested__*' as Markup, mark: (props: MarkProps) => ({...props, style: {fontStyle: 'italic'}})}, + { + markup: '`__value__`' as Markup, + mark: (props: MarkProps) => ({ + ...props, + style: { + backgroundColor: '#f6f8fa', + padding: '2px 6px', + borderRadius: '3px', + fontFamily: 'monospace', + fontSize: '0.9em', + }, + }), + }, + { + markup: '```__meta__\n__value__```' as Markup, + mark: (props: MarkProps) => ({ + ...props, + style: { + display: 'block', + backgroundColor: '#f6f8fa', + padding: '12px', + borderRadius: '6px', + fontFamily: 'monospace', + fontSize: '0.9em', + whiteSpace: 'pre-wrap', + border: '1px solid #d1d9e0', + margin: '8px 0', + }, + }), + }, + { + markup: '[__value__](__meta__)' as Markup, + mark: (props: MarkProps) => ({ + ...props, + style: {color: '#0969da', textDecoration: 'underline', cursor: 'pointer'}, + }), + }, + { + markup: '~~__value__~~' as Markup, + mark: (props: MarkProps) => ({...props, style: {textDecoration: 'line-through', opacity: 0.7}}), + }, +] as Option[] -This is the first paragraph. Hover to see the drag handle on the left. +const DRAG_MARKDOWN = `# Welcome to **Marked Input** -This is the second paragraph. Try dragging it above the first one! +A powerful library for parsing rich text with markdown formatting. ## Features -- Drag handles appear on hover -- Drop indicators show where the block will land -- Blocks reorder by manipulating the underlying string`) +- **Bold** and *italic* text support + +- \`Code snippets\` and \`code blocks\` + +- ~~Strikethrough~~ for deleted content + +- Links like [GitHub](https://github.com) + +## Example + +\`\`\`javascript +const parser = new ParserV2(['**__value__**', '*__value__*']) +const result = parser.parse('Hello **world**!') +\`\`\` + +Visit our docs for more details.` + +export const MarkdownDrag: Story = { + render: () => + defineComponent({ + setup() { + const value = ref(DRAG_MARKDOWN) return () => h('div', {style: mdContainerStyle}, [ h(MarkedInput, { Mark: MarkdownMark, - options: blockLevelMarkdownOptions, + options: markdownOptions, value: value.value, drag: true, style: mdEditorStyle, From cde2903f43ddf42c474c7d84b4fa8ad3517a71fa Mon Sep 17 00:00:00 2001 From: Nowely Date: Fri, 13 Mar 2026 21:50:01 +0300 Subject: [PATCH 7/8] fix(drag): improve token handling in splitTokensIntoDragRows function - Updated the condition in splitTokensIntoDragRows to check for trimmed content, ensuring that only non-empty tokens are processed. - Enhanced documentation in sampleTexts to clarify the use of loose-list format for drag mode, improving understanding of the drag-and-drop functionality. --- .../blocks/splitTokensIntoDragRows.ts | 2 +- .../storybook/src/pages/Drag/Drag.stories.tsx | 48 +++++++++++++++++++ .../storybook/src/shared/lib/sampleTexts.ts | 6 ++- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/common/core/src/features/blocks/splitTokensIntoDragRows.ts b/packages/common/core/src/features/blocks/splitTokensIntoDragRows.ts index 4a17f652..1cb5f3c8 100644 --- a/packages/common/core/src/features/blocks/splitTokensIntoDragRows.ts +++ b/packages/common/core/src/features/blocks/splitTokensIntoDragRows.ts @@ -148,7 +148,7 @@ export function splitTokensIntoDragRows(tokens: Token[]): Block[] { }) } afterSeparatorPos = part.position.end - } else if (part.content.length > 0) { + } else if (part.content.trim().length > 0) { afterSeparatorPos = null rows.push({ id: generateRowId(part.position.start), diff --git a/packages/react/storybook/src/pages/Drag/Drag.stories.tsx b/packages/react/storybook/src/pages/Drag/Drag.stories.tsx index 75ac833f..c3ab62b5 100644 --- a/packages/react/storybook/src/pages/Drag/Drag.stories.tsx +++ b/packages/react/storybook/src/pages/Drag/Drag.stories.tsx @@ -181,6 +181,54 @@ const TODO_VALUE = `# \u{1F4CB} Project Launch Checklist > \u2610 = pending \u2611 = done` +// ─── Test helper stories (used by Drag.spec.tsx) ───────────────────────────── + +const testStyle: React.CSSProperties = {minHeight: 100, padding: 8, border: '1px solid #e0e0e0'} + +export const PlainTextDrag: Story = { + parameters: {docs: {disable: true}}, + render: () => { + const [value, setValue] = useState( + 'First block of plain text\n\nSecond block of plain text\n\nThird block of plain text\n\nFourth block of plain text\n\nFifth block of plain text' + ) + return ( + <> + + + + ) + }, +} + +export const MarkdownDrag: Story = { + parameters: {docs: {disable: true}}, + render: () => { + const [value, setValue] = useState( + '# Welcome to Draggable Blocks\n\nThis is the first paragraph.\n\nThis is the second paragraph.\n\n## Features\n\n- Drag handles appear on hover' + ) + return ( + <> + + + + ) + }, +} + +export const ReadOnlyDrag: Story = { + parameters: {docs: {disable: true}}, + render: () => , +} + +// ─── Todo list (all marks include \n\n) ─────────────────────────────────────── + export const TodoList: Story = { render: () => { const [value, setValue] = useState(TODO_VALUE) diff --git a/packages/react/storybook/src/shared/lib/sampleTexts.ts b/packages/react/storybook/src/shared/lib/sampleTexts.ts index 97c15b9c..fc1bf2ec 100644 --- a/packages/react/storybook/src/shared/lib/sampleTexts.ts +++ b/packages/react/storybook/src/shared/lib/sampleTexts.ts @@ -1,7 +1,9 @@ /** * Sample text for drag mode: each block-level element ends with \n\n so it - * matches its own mark pattern (e.g. `- __nested__\n\n`). Prose uses plain text - * to avoid inline marks becoming separate top-level drag rows. + * matches its own mark pattern (e.g. `- __nested__\n\n`). List items use + * loose-list format (blank line between items) because the markput parser + * requires an unambiguous \n\n terminator to delimit each list mark. + * Prose uses plain text to avoid inline marks becoming separate drag rows. */ export const DRAG_MARKDOWN = `# Welcome to **Marked Input** From 910a4a0c3bd636857da91c3140eebe05339086ad Mon Sep 17 00:00:00 2001 From: Nowely Date: Fri, 13 Mar 2026 21:59:17 +0300 Subject: [PATCH 8/8] feat(drag): add new drag stories for PlainText, Markdown, and ReadOnly modes - Introduced PlainTextDrag, MarkdownDrag, and ReadOnlyDrag stories to showcase drag-and-drop capabilities with different content types. - Updated the Markdown story to enhance clarity and maintain consistency with the new story structure. - Utilized markRaw for MarkdownMark in the MarkdownDrag story to improve performance and rendering. - Ensured all new stories are integrated with the existing drag-and-drop functionality. --- .../storybook/src/pages/Drag/Drag.stories.ts | 74 ++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/packages/vue/storybook/src/pages/Drag/Drag.stories.ts b/packages/vue/storybook/src/pages/Drag/Drag.stories.ts index 236b00b4..e711e0d5 100644 --- a/packages/vue/storybook/src/pages/Drag/Drag.stories.ts +++ b/packages/vue/storybook/src/pages/Drag/Drag.stories.ts @@ -1,7 +1,7 @@ import type {MarkProps, Markup, Option} from '@markput/vue' import {MarkedInput} from '@markput/vue' import type {Meta, StoryObj} from '@storybook/vue3-vite' -import {defineComponent, h, ref} from 'vue' +import {defineComponent, h, markRaw, ref} from 'vue' import Text from '../../shared/components/Text.vue' @@ -130,7 +130,7 @@ const result = parser.parse('Hello **world**!') Visit our docs for more details.` -export const MarkdownDrag: Story = { +export const Markdown: Story = { render: () => defineComponent({ setup() { @@ -261,6 +261,76 @@ const TODO_VALUE = `# \u{1F4CB} Project Launch Checklist > \u2610 = pending \u2611 = done` +// ─── Test helper stories (used by Drag.spec.ts) ────────────────────────────── + +const testStyle = {minHeight: '100px', padding: '8px', border: '1px solid #e0e0e0'} + +export const PlainTextDrag: Story = { + parameters: {docs: {disable: true}}, + render: () => + defineComponent({ + setup() { + const value = ref( + 'First block of plain text\n\nSecond block of plain text\n\nThird block of plain text\n\nFourth block of plain text\n\nFifth block of plain text' + ) + return () => + h('div', {}, [ + h(MarkedInput, { + value: value.value, + drag: true, + style: testStyle, + onChange: (v: string) => { + value.value = v + }, + }), + h(Text, {value: value.value}), + ]) + }, + }), +} + +export const MarkdownDrag: Story = { + parameters: {docs: {disable: true}}, + render: () => + defineComponent({ + setup() { + const value = ref( + '# Welcome to Draggable Blocks\n\nThis is the first paragraph.\n\nThis is the second paragraph.\n\n## Features\n\n- Drag handles appear on hover' + ) + return () => + h('div', {}, [ + h(MarkedInput, { + Mark: markRaw(MarkdownMark), + options: markdownOptions, + value: value.value, + drag: true, + style: testStyle, + onChange: (v: string) => { + value.value = v + }, + }), + h(Text, {value: value.value}), + ]) + }, + }), +} + +export const ReadOnlyDrag: Story = { + parameters: {docs: {disable: true}}, + render: () => + defineComponent({ + setup() { + return () => + h(MarkedInput, { + value: 'Read-Only Content\n\nSection A\n\nSection B', + readOnly: true, + drag: true, + style: testStyle, + }) + }, + }), +} + export const TodoListDrag: Story = { render: () => defineComponent({