@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/src/preset.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { getSchema } from '@tiptap/core'
|
|
1
2
|
import type { Extensions } from '@tiptap/core'
|
|
3
|
+
import type { Schema } from '@tiptap/pm/model'
|
|
2
4
|
import StarterKit from '@tiptap/starter-kit'
|
|
3
5
|
import { TaskList, TaskItem } from '@tiptap/extension-list'
|
|
4
6
|
import { TextStyle } from '@tiptap/extension-text-style'
|
|
@@ -14,24 +16,33 @@ import { EmbedBlock } from './nodes/embed'
|
|
|
14
16
|
import { TocBlock } from './nodes/toc'
|
|
15
17
|
import { AiAttribution } from './marks/ai'
|
|
16
18
|
import { CommentMark, CommentCommands } from './marks/comment'
|
|
17
|
-
import { PlaceholderMark, PlaceholderCommands } from './marks/placeholder'
|
|
18
19
|
import { TesseraInputRules } from './extensions/input-rules'
|
|
19
20
|
import { TesseraShortcuts } from './extensions/shortcuts'
|
|
20
21
|
import { TesseraFindReplace } from './extensions/find-replace'
|
|
21
22
|
import { SlashMenu } from './extensions/slash'
|
|
22
23
|
import { EmojiMenu } from './extensions/emoji'
|
|
23
|
-
import { TesseraHistory } from './extensions/history'
|
|
24
24
|
import { BlockContextMenu } from './extensions/context-menu'
|
|
25
25
|
import { TesseraGallery } from './extensions/gallery'
|
|
26
26
|
import { TesseraMetrics } from './extensions/metrics'
|
|
27
27
|
import { TesseraWordPaste } from './extensions/word-paste'
|
|
28
28
|
import { TesseraServices } from './services'
|
|
29
|
-
import { createTesseraT, type TesseraLocale } from './i18n'
|
|
29
|
+
import { createTesseraT, type TesseraLocale, type TesseraMessageOverrides } from './i18n'
|
|
30
30
|
|
|
31
31
|
export interface TesseraPresetOptions {
|
|
32
32
|
locale?: TesseraLocale
|
|
33
|
-
/**
|
|
34
|
-
|
|
33
|
+
/** Empty-paragraph placeholder text (default: the i18n `placeholderEmpty` string). */
|
|
34
|
+
placeholder?: string
|
|
35
|
+
/** Per-key overrides of the built-in UI dictionary (slash items, tooltips…). */
|
|
36
|
+
messages?: TesseraMessageOverrides
|
|
37
|
+
/**
|
|
38
|
+
* Host block policy: top-level block types to exclude entirely. An excluded
|
|
39
|
+
* node is dropped from the extension preset, the slash menu, UniqueID's id
|
|
40
|
+
* list and the keyboard shortcuts that produce it — the editor cannot create
|
|
41
|
+
* it and rejects its JSON on parse. Recognized names: `hint`, `collapsible`,
|
|
42
|
+
* `embedBlock`, `tocBlock`, `horizontalRule`, `table`, `taskList`,
|
|
43
|
+
* `imageBlock`.
|
|
44
|
+
*/
|
|
45
|
+
excludeBlocks?: string[]
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
/** Node types that receive stable block IDs (write-back protocol basis). */
|
|
@@ -55,12 +66,38 @@ export const ID_BLOCK_TYPES = [
|
|
|
55
66
|
'tocBlock',
|
|
56
67
|
]
|
|
57
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Every top-level block type the FULL preset can emit into the canonical JSON.
|
|
71
|
+
* Hosts gating saves server-side (a block whitelist) should align their list
|
|
72
|
+
* with this one; `excludeBlocks` trims the editor to match a narrower policy.
|
|
73
|
+
* (Nested containers — listItem/taskItem/tableRow — are not listed: they only
|
|
74
|
+
* appear inside their parents.)
|
|
75
|
+
*/
|
|
76
|
+
export const SUPPORTED_BLOCK_TYPES = [
|
|
77
|
+
'paragraph',
|
|
78
|
+
'heading',
|
|
79
|
+
'bulletList',
|
|
80
|
+
'orderedList',
|
|
81
|
+
'taskList',
|
|
82
|
+
'blockquote',
|
|
83
|
+
'codeBlock',
|
|
84
|
+
'horizontalRule',
|
|
85
|
+
'imageBlock',
|
|
86
|
+
'table',
|
|
87
|
+
'hint',
|
|
88
|
+
'collapsible',
|
|
89
|
+
'embedBlock',
|
|
90
|
+
'tocBlock',
|
|
91
|
+
] as const
|
|
92
|
+
|
|
58
93
|
/**
|
|
59
94
|
* The Tessera extension preset: framework-agnostic, UI-free. The binding layers
|
|
60
95
|
* rendering (slash menu renderer, node views) on top of this.
|
|
61
96
|
*/
|
|
62
97
|
export function createTesseraExtensions(options: TesseraPresetOptions = {}): Extensions {
|
|
63
|
-
const t = createTesseraT(options.locale ?? 'zh-CN')
|
|
98
|
+
const t = createTesseraT(options.locale ?? 'zh-CN', options.messages)
|
|
99
|
+
const excluded = new Set(options.excludeBlocks ?? [])
|
|
100
|
+
const keep = (name: string): boolean => !excluded.has(name)
|
|
64
101
|
|
|
65
102
|
return [
|
|
66
103
|
StarterKit.configure({
|
|
@@ -72,46 +109,48 @@ export function createTesseraExtensions(options: TesseraPresetOptions = {}): Ext
|
|
|
72
109
|
},
|
|
73
110
|
// undoRedo keeps defaults (newGroupDelay 500ms): streaming AI chunks
|
|
74
111
|
// arriving faster than that already merge into one undo step.
|
|
112
|
+
horizontalRule: keep('horizontalRule') ? undefined : false,
|
|
75
113
|
}),
|
|
76
114
|
TextStyle,
|
|
77
115
|
Color,
|
|
78
116
|
Highlight.configure({ multicolor: true }),
|
|
79
|
-
TaskList,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
AiTable,
|
|
87
|
-
AiTableRow,
|
|
88
|
-
AiTableCell,
|
|
89
|
-
AiTableHeader,
|
|
90
|
-
EmbedBlock,
|
|
91
|
-
TocBlock,
|
|
117
|
+
...(keep('taskList') ? [TaskList, TaskItem.configure({ nested: true })] : []),
|
|
118
|
+
...(keep('hint') ? [Hint] : []),
|
|
119
|
+
...(keep('collapsible') ? [Collapsible, CollapsibleSummary, CollapsibleContent] : []),
|
|
120
|
+
...(keep('imageBlock') ? [ImageBlock] : []),
|
|
121
|
+
...(keep('table') ? [AiTable, AiTableRow, AiTableCell, AiTableHeader] : []),
|
|
122
|
+
...(keep('embedBlock') ? [EmbedBlock] : []),
|
|
123
|
+
...(keep('tocBlock') ? [TocBlock] : []),
|
|
92
124
|
AiAttribution,
|
|
93
125
|
CommentMark,
|
|
94
126
|
CommentCommands,
|
|
95
|
-
PlaceholderMark,
|
|
96
|
-
PlaceholderCommands,
|
|
97
127
|
UniqueID.configure({
|
|
98
|
-
types: ID_BLOCK_TYPES,
|
|
128
|
+
types: ID_BLOCK_TYPES.filter(keep),
|
|
99
129
|
attributeName: 'id',
|
|
100
130
|
}),
|
|
101
131
|
Placeholder.configure({
|
|
102
|
-
placeholder: ({ node }) =>
|
|
132
|
+
placeholder: ({ node }) =>
|
|
133
|
+
node.type.name === 'paragraph' ? (options.placeholder ?? t('placeholderEmpty')) : '',
|
|
103
134
|
showOnlyWhenEditable: true,
|
|
104
135
|
}),
|
|
105
136
|
TesseraInputRules,
|
|
106
137
|
TesseraShortcuts,
|
|
107
138
|
TesseraFindReplace,
|
|
108
|
-
TesseraHistory.configure({ idleMs: options.historyIdleMs }),
|
|
109
139
|
BlockContextMenu,
|
|
110
|
-
SlashMenu.configure({ locale: options.locale ?? 'zh-CN' }),
|
|
140
|
+
SlashMenu.configure({ locale: options.locale ?? 'zh-CN', excludeItems: options.excludeBlocks }),
|
|
111
141
|
EmojiMenu,
|
|
112
|
-
TesseraGallery,
|
|
142
|
+
...(keep('imageBlock') ? [TesseraGallery] : []),
|
|
113
143
|
TesseraMetrics,
|
|
114
144
|
TesseraWordPaste,
|
|
115
145
|
TesseraServices,
|
|
116
146
|
]
|
|
117
147
|
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* ProseMirror schema for the preset without instantiating an editor — the
|
|
151
|
+
* headless entry for hosts that only need format conversion (markdown ⇄ JSON).
|
|
152
|
+
* Honors `excludeBlocks` so conversion always agrees with the mounted editor.
|
|
153
|
+
*/
|
|
154
|
+
export function createTesseraSchema(options: TesseraPresetOptions = {}): Schema {
|
|
155
|
+
return getSchema(createTesseraExtensions(options))
|
|
156
|
+
}
|
package/src/services.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Extension } from '@tiptap/core'
|
|
2
|
-
import type { JSONContent } from '@tiptap/core'
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* Injected services (ADR-0001 family): the component family never performs
|
|
@@ -18,20 +17,6 @@ export interface UploadService {
|
|
|
18
17
|
uploadFile?(file: File | Blob): Promise<UploadedAsset>
|
|
19
18
|
}
|
|
20
19
|
|
|
21
|
-
/** v1.1: persistent version history storage (snapshots keyed by time). */
|
|
22
|
-
export interface DocSnapshot {
|
|
23
|
-
id: string
|
|
24
|
-
ts: number
|
|
25
|
-
doc: JSONContent
|
|
26
|
-
label?: string
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface StorageService {
|
|
30
|
-
saveSnapshot(snapshot: DocSnapshot): Promise<void>
|
|
31
|
-
listSnapshots(): Promise<DocSnapshot[]>
|
|
32
|
-
deleteSnapshot?(id: string): Promise<void>
|
|
33
|
-
}
|
|
34
|
-
|
|
35
20
|
/** v1.1: inline comments. */
|
|
36
21
|
export interface CommentEntry {
|
|
37
22
|
id: string
|
|
@@ -55,14 +40,13 @@ export interface CommentStore {
|
|
|
55
40
|
remove(id: string): Promise<void>
|
|
56
41
|
}
|
|
57
42
|
|
|
58
|
-
/** v1.1: who is editing (
|
|
43
|
+
/** v1.1: who is editing (comment authorship). */
|
|
59
44
|
export interface IdentityService {
|
|
60
45
|
getCurrentUser(): { id: string; name: string } | null
|
|
61
46
|
}
|
|
62
47
|
|
|
63
48
|
export interface TesseraServicesStorage {
|
|
64
49
|
upload?: UploadService
|
|
65
|
-
storage?: StorageService
|
|
66
50
|
comments?: CommentStore
|
|
67
51
|
identity?: IdentityService
|
|
68
52
|
}
|
|
@@ -73,7 +57,6 @@ export const TesseraServices = Extension.create({
|
|
|
73
57
|
addStorage() {
|
|
74
58
|
return {
|
|
75
59
|
upload: undefined,
|
|
76
|
-
storage: undefined,
|
|
77
60
|
comments: undefined,
|
|
78
61
|
identity: undefined,
|
|
79
62
|
} satisfies TesseraServicesStorage
|
|
@@ -90,10 +73,6 @@ export function getUploadService(editor: ServicesEditor): UploadService | undefi
|
|
|
90
73
|
return servicesBag(editor)?.upload
|
|
91
74
|
}
|
|
92
75
|
|
|
93
|
-
export function getStorageService(editor: ServicesEditor): StorageService | undefined {
|
|
94
|
-
return servicesBag(editor)?.storage
|
|
95
|
-
}
|
|
96
|
-
|
|
97
76
|
export function getCommentStore(editor: ServicesEditor): CommentStore | undefined {
|
|
98
77
|
return servicesBag(editor)?.comments
|
|
99
78
|
}
|
package/src/styles/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/src/diff.ts
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import type { JSONContent } from '@tiptap/core'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Document diff (v1.1 history panel): block-level via stable ids (LCS on the
|
|
5
|
-
* id sequence) + word-level inside changed text blocks. Pure utility — the
|
|
6
|
-
* panel renders it.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export interface WordDiffPart {
|
|
10
|
-
text: string
|
|
11
|
-
type: 'same' | 'add' | 'del'
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface BlockDiffEntry {
|
|
15
|
-
kind: 'added' | 'removed' | 'changed' | 'unchanged'
|
|
16
|
-
id?: string
|
|
17
|
-
before?: JSONContent
|
|
18
|
-
after?: JSONContent
|
|
19
|
-
wordDiff?: WordDiffPart[]
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function topLevel(doc: JSONContent): JSONContent[] {
|
|
23
|
-
return doc.content ?? []
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function blockId(block: JSONContent): string | null {
|
|
27
|
-
const id = block.attrs?.id
|
|
28
|
-
return typeof id === 'string' ? id : null
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function collectText(node: JSONContent): string {
|
|
32
|
-
let text = ''
|
|
33
|
-
if (node.text) {
|
|
34
|
-
text += node.text
|
|
35
|
-
}
|
|
36
|
-
for (const child of node.content ?? []) {
|
|
37
|
-
text += collectText(child)
|
|
38
|
-
}
|
|
39
|
-
return text
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function tokenize(text: string): string[] {
|
|
43
|
-
return text.match(/[\u4e00-\u9fa5]|[a-zA-Z0-9]+|\s+|[^\sa-zA-Z0-9\u4e00-\u9fa5]/g) ?? []
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Classic LCS word diff with a size guard. */
|
|
47
|
-
export function wordDiff(beforeText: string, afterText: string): WordDiffPart[] {
|
|
48
|
-
const a = tokenize(beforeText)
|
|
49
|
-
const b = tokenize(afterText)
|
|
50
|
-
if (a.length * b.length > 4_000_000) {
|
|
51
|
-
return [
|
|
52
|
-
{ text: beforeText, type: 'del' },
|
|
53
|
-
{ text: afterText, type: 'add' },
|
|
54
|
-
]
|
|
55
|
-
}
|
|
56
|
-
// dp[i][j] = LCS length of a[i:], b[j:]
|
|
57
|
-
const dp: Uint32Array[] = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1))
|
|
58
|
-
for (let i = a.length - 1; i >= 0; i--) {
|
|
59
|
-
for (let j = b.length - 1; j >= 0; j--) {
|
|
60
|
-
dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!)
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
const parts: WordDiffPart[] = []
|
|
64
|
-
const push = (text: string, type: WordDiffPart['type']) => {
|
|
65
|
-
const last = parts[parts.length - 1]
|
|
66
|
-
if (last && last.type === type) {
|
|
67
|
-
last.text += text
|
|
68
|
-
} else {
|
|
69
|
-
parts.push({ text, type })
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
let i = 0
|
|
73
|
-
let j = 0
|
|
74
|
-
while (i < a.length && j < b.length) {
|
|
75
|
-
if (a[i] === b[j]) {
|
|
76
|
-
push(a[i]!, 'same')
|
|
77
|
-
i++
|
|
78
|
-
j++
|
|
79
|
-
} else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
|
|
80
|
-
push(a[i]!, 'del')
|
|
81
|
-
i++
|
|
82
|
-
} else {
|
|
83
|
-
push(b[j]!, 'add')
|
|
84
|
-
j++
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
while (i < a.length) {
|
|
88
|
-
push(a[i++]!, 'del')
|
|
89
|
-
}
|
|
90
|
-
while (j < b.length) {
|
|
91
|
-
push(b[j++]!, 'add')
|
|
92
|
-
}
|
|
93
|
-
return parts
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function sameBlock(a: JSONContent, b: JSONContent): boolean {
|
|
97
|
-
return JSON.stringify(a) === JSON.stringify(b)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function diffDocs(before: JSONContent, after: JSONContent): BlockDiffEntry[] {
|
|
101
|
-
const beforeBlocks = topLevel(before)
|
|
102
|
-
const afterBlocks = topLevel(after)
|
|
103
|
-
const afterById = new Map<string, JSONContent>()
|
|
104
|
-
for (const block of afterBlocks) {
|
|
105
|
-
const id = blockId(block)
|
|
106
|
-
if (id) {
|
|
107
|
-
afterById.set(id, block)
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
const seen = new Set<string>()
|
|
111
|
-
const entries: BlockDiffEntry[] = []
|
|
112
|
-
|
|
113
|
-
for (const block of beforeBlocks) {
|
|
114
|
-
const id = blockId(block)
|
|
115
|
-
if (id && afterById.has(id)) {
|
|
116
|
-
seen.add(id)
|
|
117
|
-
const next = afterById.get(id)!
|
|
118
|
-
if (sameBlock(block, next)) {
|
|
119
|
-
entries.push({ kind: 'unchanged', id, before: block, after: next })
|
|
120
|
-
} else {
|
|
121
|
-
entries.push({ kind: 'changed', id, before: block, after: next, wordDiff: wordDiff(collectText(block), collectText(next)) })
|
|
122
|
-
}
|
|
123
|
-
} else {
|
|
124
|
-
entries.push({ kind: 'removed', id: id ?? undefined, before: block })
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
for (const block of afterBlocks) {
|
|
128
|
-
const id = blockId(block)
|
|
129
|
-
if (id && seen.has(id)) {
|
|
130
|
-
continue
|
|
131
|
-
}
|
|
132
|
-
if (!id || !beforeBlocks.some(b => blockId(b) === id)) {
|
|
133
|
-
entries.push({ kind: 'added', id: id ?? undefined, after: block })
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
return entries
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** Compact summary for the panel header. */
|
|
140
|
-
export function diffSummary(entries: BlockDiffEntry[]): { added: number; removed: number; changed: number } {
|
|
141
|
-
let added = 0
|
|
142
|
-
let removed = 0
|
|
143
|
-
let changed = 0
|
|
144
|
-
for (const entry of entries) {
|
|
145
|
-
if (entry.kind === 'added') added++
|
|
146
|
-
else if (entry.kind === 'removed') removed++
|
|
147
|
-
else if (entry.kind === 'changed') changed++
|
|
148
|
-
}
|
|
149
|
-
return { added, removed, changed }
|
|
150
|
-
}
|