@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/index.d.ts +137 -173
- package/dist/index.js +307 -405
- package/dist/index.js.map +1 -1
- package/dist/tessera.css +132 -120
- package/package.json +1 -1
- package/src/__tests__/host-config.test.ts +115 -0
- package/src/__tests__/linkedit.test.ts +98 -0
- package/src/__tests__/table-guard.test.ts +155 -0
- package/src/__tests__/v11.test.ts +1 -50
- package/src/extensions/context-menu.ts +7 -2
- package/src/extensions/shortcuts.ts +8 -6
- package/src/extensions/slash.ts +43 -25
- package/src/i18n.ts +26 -33
- package/src/index.ts +10 -13
- package/src/linkedit.ts +66 -0
- package/src/markdown.ts +0 -1
- package/src/nodes/table.ts +130 -0
- package/src/preset.ts +65 -26
- package/src/services.ts +1 -22
- package/src/styles/tessera.css +132 -120
- package/src/diff.ts +0 -150
- package/src/extensions/history.ts +0 -133
- package/src/marks/placeholder.ts +0 -63
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
|
-
.
|
|
914
|
-
|
|
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
|
|
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:
|
|
1055
|
+
width: 210px;
|
|
959
1056
|
max-height: 320px;
|
|
960
|
-
|
|
961
|
-
|
|
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:
|
|
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:
|
|
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
|
@@ -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
|
+
'',
|
|
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
|
+
})
|