@tessera-editor/core 0.1.0 → 0.2.0

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/tessera.css CHANGED
@@ -466,6 +466,58 @@
466
466
  align-items: center;
467
467
  gap: 6px;
468
468
  }
469
+ /* click-to-edit link panel: opens anchored to a clicked link, column layout */
470
+ .tessera-link-editor {
471
+ position: fixed;
472
+ width: 320px;
473
+ z-index: 85;
474
+ display: flex;
475
+ flex-direction: column;
476
+ gap: 8px;
477
+ padding: 10px;
478
+ background: var(--te-bg);
479
+ border: 1px solid var(--te-border);
480
+ border-radius: 10px;
481
+ box-shadow: var(--te-shadow-pop);
482
+ font-family: var(--te-font);
483
+ }
484
+ .tessera-link-editor input {
485
+ width: 100%;
486
+ height: 30px;
487
+ border: 1px solid var(--te-border);
488
+ border-radius: 6px;
489
+ padding: 0 8px;
490
+ font-size: 13px;
491
+ font-family: var(--te-font);
492
+ background: var(--te-bg);
493
+ color: var(--te-text);
494
+ outline: none;
495
+ }
496
+ .tessera-link-editor input:focus {
497
+ border-color: var(--te-accent);
498
+ }
499
+ .tessera-link-editor-row {
500
+ display: flex;
501
+ gap: 6px;
502
+ }
503
+ .tessera-link-editor-row button {
504
+ border: 1px solid var(--te-border);
505
+ background: var(--te-bg);
506
+ color: var(--te-text);
507
+ cursor: pointer;
508
+ font-size: 13px;
509
+ font-family: var(--te-font);
510
+ padding: 5px 12px;
511
+ border-radius: 6px;
512
+ }
513
+ .tessera-link-editor-row button:hover:not(:disabled) {
514
+ border-color: var(--te-accent);
515
+ color: var(--te-accent);
516
+ }
517
+ .tessera-link-editor-row button:disabled {
518
+ opacity: 0.5;
519
+ cursor: not-allowed;
520
+ }
469
521
  .tessera-link-popover input {
470
522
  width: 220px;
471
523
  height: 30px;
@@ -648,8 +700,10 @@
648
700
  position: absolute;
649
701
  top: 48px;
650
702
  right: 8px;
651
- bottom: 8px;
652
703
  width: 300px;
704
+ /* hug content by default; only stretch toward a viewport-ish cap when the
705
+ conversation grows (body scrolls past the cap via flex + overflow) */
706
+ max-height: min(520px, calc(100% - 56px));
653
707
  z-index: 55;
654
708
  display: flex;
655
709
  flex-direction: column;
@@ -802,6 +856,46 @@
802
856
  max-height: 90vh;
803
857
  border-radius: 8px;
804
858
  box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
859
+ transition: transform 0.15s ease;
860
+ }
861
+ .tessera-lightbox-bar {
862
+ position: fixed;
863
+ bottom: 24px;
864
+ left: 50%;
865
+ transform: translateX(-50%);
866
+ display: flex;
867
+ align-items: center;
868
+ gap: 2px;
869
+ padding: 6px 10px;
870
+ border-radius: 8px;
871
+ background: rgba(24, 26, 34, 0.78);
872
+ color: #e8eaf0;
873
+ font-size: 12px;
874
+ font-family: var(--te-font);
875
+ user-select: none;
876
+ cursor: default;
877
+ }
878
+ .tessera-lightbox-bar button {
879
+ border: none;
880
+ background: none;
881
+ color: inherit;
882
+ cursor: pointer;
883
+ font-size: 15px;
884
+ line-height: 1;
885
+ padding: 4px 10px;
886
+ border-radius: 6px;
887
+ }
888
+ .tessera-lightbox-bar button:hover {
889
+ background: rgba(255, 255, 255, 0.14);
890
+ }
891
+ .tessera-lightbox-scale {
892
+ min-width: 46px;
893
+ text-align: center;
894
+ font-variant-numeric: tabular-nums;
895
+ }
896
+ .tessera-lightbox-hint {
897
+ margin-left: 8px;
898
+ opacity: 0.55;
805
899
  }
806
900
 
807
901
  /* ---------- v1.1 additions: gallery / emoji picker ---------- */
@@ -909,16 +1003,18 @@
909
1003
  display: table-cell;
910
1004
  }
911
1005
 
912
- /* freeze first column (Slite behavior) */
913
- .tessera-doc table[data-freeze-first='true'] td:first-child,
914
- .tessera-doc table[data-freeze-first='true'] th:first-child {
1006
+ /* freeze first column (Slite behavior). NOT :first-child — every cell sits
1007
+ inside its own .react-renderer wrapper, so each one IS a :first-child
1008
+ there; select by the column index the node views render instead. */
1009
+ .tessera-doc table[data-freeze-first='true'] td[data-index='0'],
1010
+ .tessera-doc table[data-freeze-first='true'] th[data-index='0'] {
915
1011
  position: sticky;
916
1012
  left: 0;
917
1013
  background: #fff;
918
1014
  z-index: 2;
919
1015
  box-shadow: 1px 0 0 var(--te-border);
920
1016
  }
921
- .tessera-doc table[data-freeze-first='true'] th:first-child {
1017
+ .tessera-doc table[data-freeze-first='true'] th[data-index='0'] {
922
1018
  background: #fafbfc;
923
1019
  }
924
1020
 
@@ -954,18 +1050,25 @@
954
1050
 
955
1051
  .tessera-col-menu {
956
1052
  flex-direction: column;
1053
+ flex-wrap: nowrap; /* .tessera-popover wraps; wrapped columns would spill sideways */
957
1054
  align-items: stretch;
958
- width: 190px;
1055
+ width: 210px;
959
1056
  max-height: 320px;
960
- overflow-y: auto;
961
- top: calc(100% + 6px);
1057
+ /* the menu is fixed-positioned and portaled out of the table; the old
1058
+ top: calc(100%+6px) anchor only made sense for the inline variant */
1059
+ top: auto;
1060
+ overflow: hidden auto;
962
1061
  }
963
1062
 
964
1063
  .tessera-menu-section {
965
1064
  display: grid;
966
- grid-template-columns: 1fr 1fr;
1065
+ grid-template-columns: repeat(2, minmax(0, 1fr));
967
1066
  gap: 2px;
968
1067
  }
1068
+ .tessera-menu-section button {
1069
+ overflow: hidden;
1070
+ text-overflow: ellipsis;
1071
+ }
969
1072
 
970
1073
  .tessera-menu-sep {
971
1074
  height: 1px;
@@ -1087,8 +1190,7 @@
1087
1190
  border-bottom: 2px dotted #e0c96b;
1088
1191
  }
1089
1192
 
1090
- .tessera-comment-panel,
1091
- .tessera-history-panel {
1193
+ .tessera-comment-panel {
1092
1194
  position: absolute;
1093
1195
  top: 48px;
1094
1196
  right: 8px;
@@ -1105,106 +1207,6 @@
1105
1207
  overflow: hidden;
1106
1208
  }
1107
1209
 
1108
- .tessera-history-body {
1109
- flex: 1;
1110
- display: grid;
1111
- grid-template-columns: 168px 1fr;
1112
- overflow: hidden;
1113
- }
1114
-
1115
- .tessera-history-list {
1116
- border-right: 1px solid var(--te-border);
1117
- overflow-y: auto;
1118
- padding: 6px;
1119
- }
1120
-
1121
- .tessera-history-item {
1122
- display: flex;
1123
- flex-direction: column;
1124
- gap: 2px;
1125
- width: 100%;
1126
- border: none;
1127
- background: none;
1128
- text-align: left;
1129
- padding: 8px;
1130
- border-radius: 6px;
1131
- cursor: pointer;
1132
- font-size: 12px;
1133
- }
1134
-
1135
- .tessera-history-item[data-selected='true'],
1136
- .tessera-history-item:hover {
1137
- background: var(--te-bg-hover);
1138
- }
1139
-
1140
- .tessera-history-time {
1141
- color: var(--te-text-soft);
1142
- font-variant-numeric: tabular-nums;
1143
- }
1144
-
1145
- .tessera-history-label {
1146
- color: var(--te-text);
1147
- font-weight: 600;
1148
- }
1149
-
1150
- .tessera-history-action {
1151
- color: var(--te-accent);
1152
- font-size: 12px;
1153
- }
1154
-
1155
- .tessera-history-diff {
1156
- overflow-y: auto;
1157
- padding: 10px;
1158
- font-size: 12.5px;
1159
- }
1160
-
1161
- .tessera-diff-summary {
1162
- color: var(--te-text-faint);
1163
- padding-bottom: 8px;
1164
- border-bottom: 1px dashed var(--te-border);
1165
- margin-bottom: 8px;
1166
- }
1167
-
1168
- .tessera-diff-block {
1169
- padding: 6px 8px;
1170
- border-radius: 6px;
1171
- margin-bottom: 6px;
1172
- border: 1px solid var(--te-border);
1173
- }
1174
-
1175
- .tessera-diff-badge {
1176
- display: inline-block;
1177
- font-size: 10px;
1178
- padding: 1px 6px;
1179
- border-radius: 4px;
1180
- margin-right: 6px;
1181
- text-transform: uppercase;
1182
- color: #fff;
1183
- }
1184
-
1185
- .tessera-diff-block--added .tessera-diff-badge { background: #31a56f; }
1186
- .tessera-diff-block--removed .tessera-diff-badge { background: #d9414f; }
1187
- .tessera-diff-block--changed .tessera-diff-badge { background: #e8912d; }
1188
-
1189
- .tessera-diff-part--add { background: #e3f7ec; text-decoration: none; }
1190
- .tessera-diff-part--del { background: #fde7e9; text-decoration: line-through; }
1191
-
1192
- .tessera-history-actions {
1193
- display: flex;
1194
- gap: 4px;
1195
- }
1196
-
1197
- .tessera-history-actions button {
1198
- border: none;
1199
- background: var(--te-bg-soft);
1200
- border-radius: 6px;
1201
- height: 24px;
1202
- padding: 0 10px;
1203
- font-size: 12px;
1204
- cursor: pointer;
1205
- color: var(--te-text);
1206
- }
1207
-
1208
1210
  /* comments */
1209
1211
  .tessera-thread {
1210
1212
  border: 1px solid var(--te-border);
@@ -1311,15 +1313,7 @@
1311
1313
  opacity: 0.5;
1312
1314
  }
1313
1315
 
1314
- /* ---------- v1.1: placeholder mark / embed / toc ---------- */
1315
-
1316
- .tessera-doc [data-type='tessera-placeholder'] {
1317
- border-bottom: 2px dashed #b6bcc4;
1318
- background: #f6f7f9;
1319
- border-radius: 3px;
1320
- padding: 0 3px;
1321
- color: var(--te-text-soft);
1322
- }
1316
+ /* ---------- v1.1: embed / toc ---------- */
1323
1317
 
1324
1318
  .tessera-embed {
1325
1319
  margin: 0.6em 0;
@@ -1489,3 +1483,21 @@
1489
1483
  user-select: none;
1490
1484
  padding: 2px;
1491
1485
  }
1486
+
1487
+ /* ---------- read-only mode (host passes `editable={false}`) ---------- */
1488
+
1489
+ /* the doc can't be focused, so toolbars/menus never trigger; hide what would
1490
+ still render. The drag handle floats outside .tessera-root (body-appended),
1491
+ hence the :has() form. */
1492
+ .tessera-root[data-readonly='true'] .tessera-doc {
1493
+ cursor: default;
1494
+ }
1495
+ .tessera-root[data-readonly='true'] .tessera-doc a {
1496
+ cursor: pointer;
1497
+ }
1498
+ .tessera-root[data-readonly='true'] .tessera-col-menu-btn {
1499
+ display: none;
1500
+ }
1501
+ body:has(.tessera-root[data-readonly='true']) .drag-handle {
1502
+ display: none !important;
1503
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tessera-editor/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Tessera framework-agnostic editor core",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,115 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from 'vitest'
3
+ import {
4
+ createTesseraSchema,
5
+ filterSlashItems,
6
+ defaultSlashItems,
7
+ createTesseraT,
8
+ } from '../index'
9
+ import { docToMarkdown, markdownToDoc } from '../markdown'
10
+
11
+ /**
12
+ * Host configuration surface (block policy + headless schema factory):
13
+ * excludeBlocks must remove the node from the schema entirely — the editor
14
+ * can neither create nor parse it — while conversion helpers stay consistent
15
+ * with the mounted editor's schema.
16
+ */
17
+
18
+ const RESTRICTED = ['hint', 'collapsible', 'embedBlock', 'tocBlock', 'horizontalRule']
19
+
20
+ describe('createTesseraSchema / excludeBlocks', () => {
21
+ it('excluded block types are absent from the schema; kept types remain', () => {
22
+ const schema = createTesseraSchema({ locale: 'zh-CN', excludeBlocks: RESTRICTED })
23
+ for (const name of RESTRICTED) {
24
+ expect(schema.nodes[name], `${name} 应被剔除`).toBeUndefined()
25
+ }
26
+ for (const name of [
27
+ 'paragraph',
28
+ 'heading',
29
+ 'bulletList',
30
+ 'orderedList',
31
+ 'taskList',
32
+ 'blockquote',
33
+ 'codeBlock',
34
+ 'imageBlock',
35
+ 'table',
36
+ ]) {
37
+ expect(schema.nodes[name], `${name} 应保留`).toBeDefined()
38
+ }
39
+ })
40
+
41
+ it('default preset keeps every block type', () => {
42
+ const schema = createTesseraSchema()
43
+ for (const name of RESTRICTED) {
44
+ expect(schema.nodes[name]).toBeDefined()
45
+ }
46
+ })
47
+
48
+ it('markdown roundtrip works on the restricted schema', () => {
49
+ const schema = createTesseraSchema({ excludeBlocks: RESTRICTED })
50
+ const md = [
51
+ '# 背景',
52
+ '',
53
+ '正文段落。',
54
+ '',
55
+ '- 甲',
56
+ '- 乙',
57
+ '',
58
+ '1. 一',
59
+ '2. 二',
60
+ '',
61
+ '- [x] 已办',
62
+ '- [ ] 待办',
63
+ '',
64
+ '> 引用',
65
+ '',
66
+ '```ts',
67
+ 'const a = 1',
68
+ '```',
69
+ '',
70
+ '![图](https://example.com/a.png)',
71
+ ].join('\n')
72
+ const doc = markdownToDoc(md, schema)
73
+ const out = docToMarkdown(doc, schema)
74
+ expect(out).toContain('# 背景')
75
+ expect(out).toContain('正文段落。')
76
+ expect(out).toContain('甲')
77
+ expect(out).toContain('1. 一')
78
+ expect(out).toContain('[x] 已办')
79
+ expect(out).toContain('> 引用')
80
+ expect(out).toContain('```ts')
81
+ expect(out).toContain('a.png')
82
+ })
83
+ })
84
+
85
+ describe('filterSlashItems', () => {
86
+ const t = createTesseraT('zh-CN')
87
+
88
+ it('drops items whose node type is excluded (id → node aliasing)', () => {
89
+ const filtered = filterSlashItems(defaultSlashItems(t), RESTRICTED)
90
+ const ids = new Set(filtered.map(item => item.id))
91
+ for (const gone of ['hint', 'collapsible', 'embed', 'toc', 'divider']) {
92
+ expect(ids.has(gone), `${gone} 应从斜杠菜单剔除`).toBe(false)
93
+ }
94
+ for (const kept of [
95
+ 'text',
96
+ 'h1',
97
+ 'h4',
98
+ 'bulletList',
99
+ 'orderedList',
100
+ 'taskList',
101
+ 'quote',
102
+ 'codeBlock',
103
+ 'image',
104
+ 'table',
105
+ ]) {
106
+ expect(ids.has(kept), `${kept} 应保留`).toBe(true)
107
+ }
108
+ })
109
+
110
+ it('no exclusion → list unchanged', () => {
111
+ const items = defaultSlashItems(t)
112
+ expect(filterSlashItems(items)).toEqual(items)
113
+ expect(filterSlashItems(items, [])).toEqual(items)
114
+ })
115
+ })
@@ -0,0 +1,98 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from 'vitest'
3
+ import { Editor } from '@tiptap/core'
4
+ import { createTesseraExtensions } from '../preset'
5
+ import { findLinkRange, saveLinkRange, removeLinkRange } from '../linkedit'
6
+
7
+ function makeEditor() {
8
+ return new Editor({ extensions: createTesseraExtensions({ locale: 'zh-CN' }) })
9
+ }
10
+
11
+ const docWithLink = {
12
+ type: 'doc',
13
+ content: [
14
+ {
15
+ type: 'paragraph',
16
+ content: [
17
+ { type: 'text', text: 'see ' },
18
+ {
19
+ type: 'text',
20
+ text: 'Tessera docs',
21
+ marks: [{ type: 'link', attrs: { href: 'https://example.com' } }],
22
+ },
23
+ { type: 'text', text: ' now' },
24
+ ],
25
+ },
26
+ ],
27
+ }
28
+
29
+ // 'see ' occupies doc positions 1..5, the link text 5..17
30
+ const LINK_START = 5
31
+ const LINK_END = 17
32
+
33
+ describe('link click-to-edit helpers', () => {
34
+ it('finds the link range from a caret inside the link', () => {
35
+ const ed = makeEditor()
36
+ ed.commands.setContent(docWithLink)
37
+ const range = findLinkRange(ed, LINK_START + 3)
38
+ expect(range).not.toBeNull()
39
+ expect(range!.from).toBe(LINK_START)
40
+ expect(range!.to).toBe(LINK_END)
41
+ expect(range!.text).toBe('Tessera docs')
42
+ expect(range!.href).toBe('https://example.com')
43
+ })
44
+
45
+ it('resolves a caret parked at the link end via the pos-1 fallback', () => {
46
+ const ed = makeEditor()
47
+ ed.commands.setContent(docWithLink)
48
+ const range = findLinkRange(ed, LINK_END)
49
+ expect(range).not.toBeNull()
50
+ expect(range!.text).toBe('Tessera docs')
51
+ })
52
+
53
+ it('returns null in plain text', () => {
54
+ const ed = makeEditor()
55
+ ed.commands.setContent(docWithLink)
56
+ expect(findLinkRange(ed, 2)).toBeNull()
57
+ })
58
+
59
+ it('save keeps the text and updates only the href', () => {
60
+ const ed = makeEditor()
61
+ ed.commands.setContent(docWithLink)
62
+ const range = findLinkRange(ed, LINK_START + 1)!
63
+ saveLinkRange(ed, range, { text: range.text, href: 'https://new.example.com' })
64
+ const after = findLinkRange(ed, LINK_START + 1)!
65
+ expect(after.href).toBe('https://new.example.com')
66
+ expect(after.text).toBe('Tessera docs')
67
+ expect(ed.state.doc.textBetween(1, ed.state.doc.content.size, '\n')).toBe('see Tessera docs now')
68
+ })
69
+
70
+ it('save replaces the display text while keeping the link', () => {
71
+ const ed = makeEditor()
72
+ ed.commands.setContent(docWithLink)
73
+ const range = findLinkRange(ed, LINK_START + 1)!
74
+ saveLinkRange(ed, range, { text: '官方文档', href: range.href })
75
+ const after = findLinkRange(ed, LINK_START + 1)!
76
+ expect(after.text).toBe('官方文档')
77
+ expect(after.href).toBe('https://example.com')
78
+ expect(ed.state.doc.textBetween(1, ed.state.doc.content.size, '\n')).toBe('see 官方文档 now')
79
+ })
80
+
81
+ it('remove strips the link mark but keeps the text', () => {
82
+ const ed = makeEditor()
83
+ ed.commands.setContent(docWithLink)
84
+ const range = findLinkRange(ed, LINK_START + 1)!
85
+ removeLinkRange(ed, range)
86
+ expect(findLinkRange(ed, LINK_START + 1)).toBeNull()
87
+ expect(ed.state.doc.textBetween(1, ed.state.doc.content.size, '\n')).toBe('see Tessera docs now')
88
+ })
89
+
90
+ it('saving an empty href degrades to plain text', () => {
91
+ const ed = makeEditor()
92
+ ed.commands.setContent(docWithLink)
93
+ const range = findLinkRange(ed, LINK_START + 1)!
94
+ saveLinkRange(ed, range, { text: range.text, href: ' ' })
95
+ expect(findLinkRange(ed, LINK_START + 1)).toBeNull()
96
+ expect(ed.state.doc.textBetween(1, ed.state.doc.content.size, '\n')).toBe('see Tessera docs now')
97
+ })
98
+ })
@@ -0,0 +1,155 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from 'vitest'
3
+ import { Editor } from '@tiptap/core'
4
+ import { createTesseraExtensions } from '../preset'
5
+ import { cellKindAt } from '../nodes/table'
6
+
7
+ function makeEditor() {
8
+ return new Editor({ extensions: createTesseraExtensions({ locale: 'zh-CN' }) })
9
+ }
10
+
11
+ const docWithTypedTable = {
12
+ type: 'doc',
13
+ content: [
14
+ {
15
+ type: 'table',
16
+ attrs: { types: ['text', 'date'] },
17
+ content: [
18
+ {
19
+ type: 'tableRow',
20
+ content: [
21
+ { type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: '任务' }] }] },
22
+ { type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: '日期' }] }] },
23
+ ],
24
+ },
25
+ {
26
+ type: 'tableRow',
27
+ content: [
28
+ { type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: '行A' }] }] },
29
+ {
30
+ type: 'tableCell',
31
+ attrs: { value: '2026-01-01' },
32
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: '残留文本' }] }],
33
+ },
34
+ ],
35
+ },
36
+ ],
37
+ },
38
+ ],
39
+ }
40
+
41
+ function cellPositions(editor: Editor) {
42
+ const out = { text: -1, typed: -1, typedFrom: 0, typedTo: 0 }
43
+ editor.state.doc.descendants((node, pos) => {
44
+ if (node.type.name === 'tableCell') {
45
+ const text = node.textContent
46
+ if (text === '行A') out.text = pos + 1
47
+ if (text === '残留文本') {
48
+ out.typed = pos + 1
49
+ out.typedFrom = pos + 1
50
+ out.typedTo = pos + node.nodeSize - 1
51
+ }
52
+ }
53
+ return undefined as never
54
+ })
55
+ return out
56
+ }
57
+
58
+ describe('typed cell guard', () => {
59
+ it('cellKindAt resolves typed vs text vs non-cell positions', () => {
60
+ const ed = makeEditor()
61
+ ed.commands.setContent(docWithTypedTable)
62
+ const p = cellPositions(ed)
63
+ expect(cellKindAt(ed.state.doc, p.typed)).toBe('date')
64
+ expect(cellKindAt(ed.state.doc, p.text)).toBe('text')
65
+ expect(cellKindAt(ed.state.doc, 0)).toBeNull()
66
+ })
67
+
68
+ it('blocks text input and clicks in typed cells only', () => {
69
+ const ed = makeEditor()
70
+ ed.commands.setContent(docWithTypedTable)
71
+ const p = cellPositions(ed)
72
+ // someProp aggregates plugins; capture every handler's verdict instead
73
+ const results: unknown[] = []
74
+ const tryProp = (prop: string, run: (f: unknown) => unknown) => {
75
+ results.length = 0
76
+ ;(ed.view.someProp as (prop2: string, f: (x: unknown) => unknown) => unknown)(prop, f => {
77
+ results.push(run(f))
78
+ return undefined // keep aggregating every plugin's verdict
79
+ })
80
+ return results
81
+ }
82
+ const inputTyped = tryProp('handleTextInput', f =>
83
+ (f as (v: unknown, from: number, to: number, text: string) => boolean)(ed.view, p.typed, p.typed, 'x'),
84
+ )
85
+ expect(inputTyped.some(v => v === true)).toBe(true) // typed cell: input blocked
86
+ const inputText = tryProp('handleTextInput', f =>
87
+ (f as (v: unknown, from: number, to: number, text: string) => boolean)(ed.view, p.text, p.text, 'x'),
88
+ )
89
+ expect(inputText.every(v => v !== true)).toBe(true) // text cell: normal editing
90
+ const clickTyped = tryProp('handleClick', f =>
91
+ (f as (v: unknown, pos: number, e: unknown) => boolean)(ed.view, p.typed, {}),
92
+ )
93
+ expect(clickTyped.some(v => v === true)).toBe(true)
94
+ })
95
+
96
+ it('atomicRanges covers typed cell content so the caret skips it', () => {
97
+ const ed = makeEditor()
98
+ ed.commands.setContent(docWithTypedTable)
99
+ const p = cellPositions(ed)
100
+ const collected: { from: number; to: number }[][] = []
101
+ ;(ed.view.someProp as (prop: string, f: (x: unknown) => unknown) => unknown)('atomicRanges', f => {
102
+ collected.push((f as (s: unknown) => { from: number; to: number }[])(ed.state))
103
+ return undefined
104
+ })
105
+ const ranges = collected.flat()
106
+ expect(ranges.some(r => r.from <= p.typed && r.to >= p.typedTo)).toBe(true)
107
+ })
108
+
109
+ it('normalizeTypedCells strips stray text in typed cells only', () => {
110
+ const ed = makeEditor()
111
+ ed.commands.setContent(docWithTypedTable)
112
+ expect(ed.commands.normalizeTypedCells()).toBe(true)
113
+ const table = ed.state.doc.nodeAt(0)!
114
+ const bodyRow = table.child(1)
115
+ expect(bodyRow.child(0).textContent).toBe('行A') // text column untouched
116
+ expect(bodyRow.child(1).textContent).toBe('') // typed column cleaned
117
+ const header = table.child(0)
118
+ expect(header.child(1).textContent).toBe('日期') // header text untouched
119
+ })
120
+
121
+ it('setColumnType to a typed kind clears that column’s stray text', () => {
122
+ const ed = makeEditor()
123
+ const plain = {
124
+ type: 'doc',
125
+ content: [
126
+ {
127
+ type: 'table',
128
+ attrs: { types: ['text', 'text'] },
129
+ content: [
130
+ {
131
+ type: 'tableRow',
132
+ content: [
133
+ { type: 'tableHeader', content: [{ type: 'paragraph' }] },
134
+ { type: 'tableHeader', content: [{ type: 'paragraph' }] },
135
+ ],
136
+ },
137
+ {
138
+ type: 'tableRow',
139
+ content: [
140
+ { type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A1' }] }] },
141
+ { type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'B1' }] }] },
142
+ ],
143
+ },
144
+ ],
145
+ },
146
+ ],
147
+ }
148
+ ed.commands.setContent(plain)
149
+ ed.commands.setColumnType(1, 'number')
150
+ const table = ed.state.doc.nodeAt(0)!
151
+ const bodyRow = table.child(1)
152
+ expect(bodyRow.child(0).textContent).toBe('A1')
153
+ expect(bodyRow.child(1).textContent).toBe('')
154
+ })
155
+ })