@estiva-app/ui 0.65.1 → 0.65.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BlockNodes.d.ts.map +1 -1
- package/dist/RichText.d.ts +2 -0
- package/dist/RichText.d.ts.map +1 -1
- package/dist/editor.js +15 -2
- package/dist/editor.js.map +2 -2
- package/dist/index.js +21 -2
- package/dist/index.js.map +2 -2
- package/package.json +1 -1
- package/registry.json +1 -1
- package/src/BlockNodes.test.tsx +96 -0
- package/src/BlockNodes.tsx +29 -3
- package/src/RichText.stories.tsx +27 -0
- package/src/RichText.test.tsx +35 -0
- package/src/RichText.tsx +40 -8
package/package.json
CHANGED
package/registry.json
CHANGED
package/src/BlockNodes.test.tsx
CHANGED
|
@@ -477,3 +477,99 @@ describe('what it draws', () => {
|
|
|
477
477
|
expect(container.querySelector('h1')?.getAttribute('data-block-id')).toBe('h1')
|
|
478
478
|
})
|
|
479
479
|
})
|
|
480
|
+
|
|
481
|
+
describe('Tab on a list item — MAN-13', () => {
|
|
482
|
+
/** A key as a person presses it: through the editor's own key handling, not a command. */
|
|
483
|
+
const press = (key: string, shiftKey = false) =>
|
|
484
|
+
editor!.view.someProp('handleKeyDown', (f) => f(editor!.view, new KeyboardEvent('keydown', { key, shiftKey })))
|
|
485
|
+
const LIST = {
|
|
486
|
+
type: 'doc',
|
|
487
|
+
content: [
|
|
488
|
+
{
|
|
489
|
+
type: 'bulletList',
|
|
490
|
+
attrs: { blockId: 'l1' },
|
|
491
|
+
content: [
|
|
492
|
+
{ type: 'listItem', attrs: { blockId: 'i1' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'intro' }] }] },
|
|
493
|
+
{ type: 'listItem', attrs: { blockId: 'i2' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'after' }] }] },
|
|
494
|
+
],
|
|
495
|
+
},
|
|
496
|
+
],
|
|
497
|
+
}
|
|
498
|
+
/** The end of "after": 1 opens the list, 2 the first item, 3 its paragraph, then "intro", two closes, two opens. */
|
|
499
|
+
const END_OF_AFTER = 1 + 1 + 1 + 5 + 2 + 2 + 5
|
|
500
|
+
|
|
501
|
+
it('puts the item in a list inside the one above, keeping its id, and gives that list none', () => {
|
|
502
|
+
editor = new Editor({ extensions: EXTENSIONS, content: LIST })
|
|
503
|
+
editor.commands.setTextSelection(END_OF_AFTER)
|
|
504
|
+
expect(press('Tab')).toBe(true)
|
|
505
|
+
const list = (editor.getJSON() as Json).content![0]
|
|
506
|
+
expect(list.content!.map((n) => n.attrs?.blockId)).toEqual(['i1'])
|
|
507
|
+
const inner = list.content![0].content![1]
|
|
508
|
+
expect(inner.type).toBe('bulletList')
|
|
509
|
+
expect(inner.attrs?.blockId ?? null).toBeNull()
|
|
510
|
+
expect(inner.content![0].attrs?.blockId).toBe('i2')
|
|
511
|
+
expect(JSON.stringify(inner)).toContain('after')
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
it('names an item made by Enter inside the inner list, so a save does not re-mint it each time', () => {
|
|
515
|
+
editor = new Editor({ extensions: EXTENSIONS, content: LIST })
|
|
516
|
+
editor.commands.setTextSelection(END_OF_AFTER)
|
|
517
|
+
press('Tab')
|
|
518
|
+
press('Enter')
|
|
519
|
+
editor.commands.insertContent('third')
|
|
520
|
+
const inner = (editor.getJSON() as Json).content![0].content![0].content![1]
|
|
521
|
+
const ids = inner.content!.map((n) => n.attrs?.blockId)
|
|
522
|
+
expect(ids[0]).toBe('i2')
|
|
523
|
+
expect(typeof ids[1]).toBe('string')
|
|
524
|
+
expect(ids[1]).not.toBe('i2')
|
|
525
|
+
expect(inner.attrs?.blockId ?? null).toBeNull()
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
it('names an item in an inner list that arrives without an id, as it does one in the outer list', async () => {
|
|
529
|
+
const nested = structuredClone(LIST)
|
|
530
|
+
const [first, second] = nested.content[0].content
|
|
531
|
+
;(first.content as unknown[]).push({ type: 'bulletList', content: [{ ...second, attrs: {} }] })
|
|
532
|
+
nested.content[0].content = [first]
|
|
533
|
+
editor = new Editor({ extensions: EXTENSIONS, content: nested })
|
|
534
|
+
// The editor's `create` comes a tick after it mounts.
|
|
535
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
536
|
+
const inner = (editor.getJSON() as Json).content![0].content![0].content![1]
|
|
537
|
+
expect(typeof inner.content![0].attrs?.blockId).toBe('string')
|
|
538
|
+
expect(inner.attrs?.blockId ?? null).toBeNull()
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('brings it back out with Shift+Tab, id and text kept', () => {
|
|
542
|
+
editor = new Editor({ extensions: EXTENSIONS, content: LIST })
|
|
543
|
+
editor.commands.setTextSelection(END_OF_AFTER)
|
|
544
|
+
press('Tab')
|
|
545
|
+
press('Tab', true)
|
|
546
|
+
const list = (editor.getJSON() as Json).content![0]
|
|
547
|
+
expect(list.content!.map((n) => n.attrs?.blockId)).toEqual(['i1', 'i2'])
|
|
548
|
+
expect(list.content![0].content).toHaveLength(1)
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
it('clears an id an inner list arrives with, since the save writes no block for it', () => {
|
|
552
|
+
const nested = structuredClone(LIST)
|
|
553
|
+
const [first, second] = nested.content[0].content
|
|
554
|
+
;(first.content as unknown[]).push({ type: 'bulletList', attrs: { blockId: 'stray' }, content: [second] })
|
|
555
|
+
nested.content[0].content = [first]
|
|
556
|
+
editor = new Editor({ extensions: EXTENSIONS, content: nested })
|
|
557
|
+
// Any change that brings in a block runs the check; Enter at the end of "after" is one.
|
|
558
|
+
editor.commands.setTextSelection(END_OF_AFTER)
|
|
559
|
+
press('Enter')
|
|
560
|
+
expect(JSON.stringify(editor.getJSON())).not.toContain('stray')
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('never lets an inner list keep a real block\'s id: the block keeps it', () => {
|
|
564
|
+
const nested = structuredClone(LIST)
|
|
565
|
+
const [first, second] = nested.content[0].content
|
|
566
|
+
;(first.content as unknown[]).push({ type: 'bulletList', attrs: { blockId: 'i1' }, content: [second] })
|
|
567
|
+
nested.content[0].content = [first]
|
|
568
|
+
editor = new Editor({ extensions: EXTENSIONS, content: nested })
|
|
569
|
+
editor.commands.setTextSelection(END_OF_AFTER)
|
|
570
|
+
press('Enter')
|
|
571
|
+
const item = (editor.getJSON() as Json).content![0].content![0]
|
|
572
|
+
expect(item.attrs?.blockId).toBe('i1')
|
|
573
|
+
expect(item.content![1].attrs?.blockId ?? null).toBeNull()
|
|
574
|
+
})
|
|
575
|
+
})
|
package/src/BlockNodes.tsx
CHANGED
|
@@ -72,9 +72,12 @@ interface Claim {
|
|
|
72
72
|
/** Every block that claims an id, by id, in document order. */
|
|
73
73
|
function claimsIn(doc: ProseMirrorNode): Map<string, Claim[]> {
|
|
74
74
|
const claims = new Map<string, Claim[]>()
|
|
75
|
-
doc.descendants((node, pos) => {
|
|
75
|
+
doc.descendants((node, pos, parent) => {
|
|
76
76
|
const fixed = node.type.name === 'unknownBlock'
|
|
77
77
|
if (!fixed && !BLOCK_TYPE_SET.has(node.type.name)) return
|
|
78
|
+
// A list inside a list item is no block (MAN-13): an id it arrived with is
|
|
79
|
+
// cleared, never a claim that could take a real block's.
|
|
80
|
+
if (parent?.type.name === 'listItem' && LISTS.has(node.type.name)) return
|
|
78
81
|
const id = node.attrs.blockId
|
|
79
82
|
if (typeof id !== 'string' || id === '') return
|
|
80
83
|
const copies = claims.get(id)
|
|
@@ -151,6 +154,22 @@ const BLOCK_CONTAINERS = new Set(['bulletList', 'orderedList', 'table', 'tableRo
|
|
|
151
154
|
/** The wrappers the save flattens: their paragraphs become inline runs of theirs. */
|
|
152
155
|
const FLATTENED = new Set(['listItem', 'blockquote'])
|
|
153
156
|
|
|
157
|
+
/**
|
|
158
|
+
* The lists Tab puts inside a list item (MAN-13). §13.3 keeps a list flat and
|
|
159
|
+
* marks an item's depth, so the save walks into such a list for its items and
|
|
160
|
+
* writes no block for the list itself: its items get ids, it gets none.
|
|
161
|
+
*/
|
|
162
|
+
const LISTS = new Set(['bulletList', 'orderedList'])
|
|
163
|
+
|
|
164
|
+
/** The lists inside a list item, with where each starts. */
|
|
165
|
+
function innerLists(item: ProseMirrorNode, pos: number): { pos: number; node: ProseMirrorNode }[] {
|
|
166
|
+
const out: { pos: number; node: ProseMirrorNode }[] = []
|
|
167
|
+
item.forEach((child, offset) => {
|
|
168
|
+
if (LISTS.has(child.type.name)) out.push({ pos: pos + 1 + offset, node: child })
|
|
169
|
+
})
|
|
170
|
+
return out
|
|
171
|
+
}
|
|
172
|
+
|
|
154
173
|
const idOf = (node: ProseMirrorNode): string | undefined => {
|
|
155
174
|
const id = node.attrs.blockId
|
|
156
175
|
return typeof id === 'string' && id !== '' ? id : undefined
|
|
@@ -163,6 +182,9 @@ function addressedBlocks(doc: ProseMirrorNode): { pos: number; node: ProseMirror
|
|
|
163
182
|
if (!BLOCK_TYPE_SET.has(node.type.name)) return
|
|
164
183
|
out.push({ pos, node })
|
|
165
184
|
if (BLOCK_CONTAINERS.has(node.type.name)) node.forEach((child, offset) => visit(child, pos + 1 + offset))
|
|
185
|
+
if (node.type.name === 'listItem') {
|
|
186
|
+
for (const list of innerLists(node, pos)) list.node.forEach((child, offset) => visit(child, list.pos + 1 + offset))
|
|
187
|
+
}
|
|
166
188
|
}
|
|
167
189
|
doc.forEach((node, offset) => visit(node, offset))
|
|
168
190
|
return out
|
|
@@ -175,13 +197,17 @@ function unnamedBlocks(doc: ProseMirrorNode): number[] {
|
|
|
175
197
|
.map((b) => b.pos)
|
|
176
198
|
}
|
|
177
199
|
|
|
178
|
-
/**
|
|
200
|
+
/**
|
|
201
|
+
* Where a paragraph inside a list item or a quote claims an id, or a list
|
|
202
|
+
* inside a list item does — both of which the save drops.
|
|
203
|
+
*/
|
|
179
204
|
function hiddenIds(doc: ProseMirrorNode): number[] {
|
|
180
205
|
const out: number[] = []
|
|
181
206
|
doc.descendants((node, pos) => {
|
|
182
207
|
if (!FLATTENED.has(node.type.name)) return
|
|
183
208
|
node.forEach((child, offset) => {
|
|
184
|
-
|
|
209
|
+
const inner = child.isTextblock || (node.type.name === 'listItem' && LISTS.has(child.type.name))
|
|
210
|
+
if (inner && idOf(child) !== undefined) out.push(pos + 1 + offset)
|
|
185
211
|
})
|
|
186
212
|
})
|
|
187
213
|
return out
|
package/src/RichText.stories.tsx
CHANGED
|
@@ -84,6 +84,33 @@ export const WithTheAppsOwnParts: Story = {
|
|
|
84
84
|
},
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/** Items indented under the one above, two levels deep, in both lists: each level is the type of the list it sits in. */
|
|
88
|
+
export const IndentedItems: Story = {
|
|
89
|
+
args: {
|
|
90
|
+
blocks: [
|
|
91
|
+
{
|
|
92
|
+
type: 'bulletList',
|
|
93
|
+
children: [
|
|
94
|
+
{ type: 'listItem', inline: [t('Item one')] },
|
|
95
|
+
{ type: 'listItem', indent: 1, inline: [t('Item one, under it')] },
|
|
96
|
+
{ type: 'listItem', indent: 2, inline: [t('A level further in')] },
|
|
97
|
+
{ type: 'listItem', indent: 1, inline: [t('Back one level')] },
|
|
98
|
+
{ type: 'listItem', inline: [t('Item two')] },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
type: 'orderedList',
|
|
103
|
+
children: [
|
|
104
|
+
{ type: 'listItem', inline: [t('The first step')] },
|
|
105
|
+
{ type: 'listItem', indent: 1, inline: [t('Each level counts from one')] },
|
|
106
|
+
{ type: 'listItem', indent: 1, inline: [t('Then two')] },
|
|
107
|
+
{ type: 'listItem', inline: [t('The second step')] },
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
87
114
|
/** A block this part has no design for, from a later version: its words are drawn, never dropped. */
|
|
88
115
|
export const UnknownBlock: Story = {
|
|
89
116
|
args: {
|
package/src/RichText.test.tsx
CHANGED
|
@@ -147,3 +147,38 @@ describe('RichText', () => {
|
|
|
147
147
|
expect(richTextClassName('default', 'outline-none')).toContain('outline-none')
|
|
148
148
|
})
|
|
149
149
|
})
|
|
150
|
+
|
|
151
|
+
describe('an indented list item — MAN-13', () => {
|
|
152
|
+
const item = (id: string, text: string, indent?: number): RichTextBlock => ({ type: 'listItem', id, inline: [t(text)], ...(indent ? { indent } : {}) })
|
|
153
|
+
|
|
154
|
+
it('draws an item inside the one above, in a list of the same type with no id', () => {
|
|
155
|
+
const { container } = render(
|
|
156
|
+
<RichText blocks={[{ type: 'orderedList', id: 'l1', children: [item('i1', 'intro'), item('i2', 'after', 1), item('i3', 'deeper', 2), item('i4', 'back')] }]} />,
|
|
157
|
+
)
|
|
158
|
+
const outer = container.querySelector('ol[data-block-id="l1"]')!
|
|
159
|
+
expect([...outer.children].map((li) => li.getAttribute('data-block-id'))).toEqual(['i1', 'i4'])
|
|
160
|
+
const inner = outer.querySelector(':scope > li > ol')!
|
|
161
|
+
expect(inner.hasAttribute('data-block-id')).toBe(false)
|
|
162
|
+
expect(inner.querySelector(':scope > li')?.getAttribute('data-block-id')).toBe('i2')
|
|
163
|
+
expect(inner.querySelector(':scope > li > ol > li')?.textContent).toBe('deeper')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('never draws an item more than one level below the one before it', () => {
|
|
167
|
+
const { container } = render(<RichText blocks={[{ type: 'bulletList', children: [item('a', 'first', 2), item('b', 'next', 3)] }]} />)
|
|
168
|
+
const outer = container.querySelector('ul')!
|
|
169
|
+
expect([...outer.children].map((li) => li.getAttribute('data-block-id'))).toEqual(['a'])
|
|
170
|
+
expect(outer.querySelector(':scope > li > ul > li')?.getAttribute('data-block-id')).toBe('b')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('never nests deeper than 8 levels (SPEC §13.3)', () => {
|
|
174
|
+
const deep = Array.from({ length: 20 }, (_, i) => item(`d${i}`, `level ${i}`, i))
|
|
175
|
+
const { container } = render(<RichText blocks={[{ type: 'bulletList', children: deep }]} />)
|
|
176
|
+
expect(container.querySelectorAll('ul').length).toBe(9)
|
|
177
|
+
expect(container.querySelector('[data-block-id="d19"]')!.parentElement).toBe(container.querySelector('[data-block-id="d8"]')!.parentElement)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('draws an item with an indent that is not a whole number above 0 at the left', () => {
|
|
181
|
+
const { container } = render(<RichText blocks={[{ type: 'bulletList', children: [item('a', 'one'), { ...item('b', 'two'), indent: -1 }, { ...item('c', 'three'), indent: Number.NaN }]}]} />)
|
|
182
|
+
expect([...container.querySelector('ul')!.children].map((li) => li.getAttribute('data-block-id'))).toEqual(['a', 'b', 'c'])
|
|
183
|
+
})
|
|
184
|
+
})
|
package/src/RichText.tsx
CHANGED
|
@@ -49,6 +49,8 @@ export interface RichTextBlock {
|
|
|
49
49
|
language?: string
|
|
50
50
|
/** A numbered list's first number. */
|
|
51
51
|
start?: number
|
|
52
|
+
/** How far a list item is indented under the items above it. A list's items stay flat; this draws them nested. */
|
|
53
|
+
indent?: number
|
|
52
54
|
attrs?: Record<string, unknown>
|
|
53
55
|
inline?: readonly RichTextRun[]
|
|
54
56
|
children?: readonly RichTextBlock[]
|
|
@@ -237,6 +239,31 @@ function Table({ block, draw }: { block: RichTextBlock; draw: Draw }) {
|
|
|
237
239
|
)
|
|
238
240
|
}
|
|
239
241
|
|
|
242
|
+
interface NestedItem {
|
|
243
|
+
item: RichTextBlock
|
|
244
|
+
key: number
|
|
245
|
+
below: NestedItem[]
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const MAX_DEPTH = 8
|
|
249
|
+
|
|
250
|
+
/** A list's flat items, each under the last item one level up — never more than one level below the item before. */
|
|
251
|
+
function nestItems(items: readonly RichTextBlock[]): NestedItem[] {
|
|
252
|
+
const top: NestedItem[] = []
|
|
253
|
+
// The last item seen at each depth.
|
|
254
|
+
const path: NestedItem[] = []
|
|
255
|
+
items.forEach((item, key) => {
|
|
256
|
+
const asked = Number.isInteger(item.indent) && item.indent! > 0 ? item.indent! : 0
|
|
257
|
+
// SPEC §13.3: never deeper than 8, so a hostile list cannot nest thousands deep.
|
|
258
|
+
const depth = Math.min(asked, path.length, MAX_DEPTH)
|
|
259
|
+
path.length = depth
|
|
260
|
+
const node: NestedItem = { item, key, below: [] }
|
|
261
|
+
;(depth === 0 ? top : path[depth - 1].below).push(node)
|
|
262
|
+
path.push(node)
|
|
263
|
+
})
|
|
264
|
+
return top
|
|
265
|
+
}
|
|
266
|
+
|
|
240
267
|
function Block({ block, draw }: { block: RichTextBlock; draw: Draw }): ReactNode {
|
|
241
268
|
const own = draw.renderBlock?.(block)
|
|
242
269
|
if (own !== undefined) return own
|
|
@@ -251,14 +278,19 @@ function Block({ block, draw }: { block: RichTextBlock; draw: Draw }): ReactNode
|
|
|
251
278
|
return <p data-block-id={block.id}>{runs}</p>
|
|
252
279
|
case 'bulletList':
|
|
253
280
|
case 'orderedList': {
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
281
|
+
const ordered = block.type === 'orderedList'
|
|
282
|
+
const items = (nodes: readonly NestedItem[]): ReactNode[] =>
|
|
283
|
+
nodes.map(({ item, key, below }) => (
|
|
284
|
+
<li key={item.id ?? key} data-block-id={item.id}>
|
|
285
|
+
<Runs runs={item.inline} draw={draw} />
|
|
286
|
+
{/* The list an indented item sits in is drawn, not stored: it has no id, and the type of the list around it. */}
|
|
287
|
+
{below.length > 0 && (ordered ? <ol>{items(below)}</ol> : <ul>{items(below)}</ul>)}
|
|
288
|
+
</li>
|
|
289
|
+
))
|
|
290
|
+
const top = items(nestItems(block.children ?? []))
|
|
291
|
+
return ordered
|
|
292
|
+
? <ol data-block-id={block.id} start={block.start}>{top}</ol>
|
|
293
|
+
: <ul data-block-id={block.id}>{top}</ul>
|
|
262
294
|
}
|
|
263
295
|
case 'blockquote':
|
|
264
296
|
return <blockquote data-block-id={block.id}>{runs}</blockquote>
|