owl-tiptap 1.0.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/README.md +7 -0
- package/es/components/tag-input/SuggestionList.d.ts +52 -0
- package/es/components/tag-input/SuggestionList.js +132 -0
- package/es/components/tag-input/TagInput.css +101 -0
- package/es/components/tag-input/TagInput.d.ts +150 -0
- package/es/components/tag-input/TagInput.js +247 -0
- package/es/components/tag-input/TagNode.d.ts +9 -0
- package/es/components/tag-input/TagNode.js +79 -0
- package/es/components/tag-input/suggestion.d.ts +16 -0
- package/es/components/tag-input/suggestion.js +26 -0
- package/es/index.css +6 -0
- package/es/index.d.ts +3 -0
- package/es/index.js +3 -0
- package/es/utils/classNames.d.ts +6 -0
- package/es/utils/classNames.js +22 -0
- package/package.json +67 -0
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Component } from '@odoo/owl';
|
|
2
|
+
export interface SuggestionItem {
|
|
3
|
+
value: string;
|
|
4
|
+
label?: string;
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
[key: string]: any;
|
|
7
|
+
}
|
|
8
|
+
interface SuggestionListProps {
|
|
9
|
+
items: SuggestionItem[];
|
|
10
|
+
onSelect: (event: MouseEvent, item: SuggestionItem) => void;
|
|
11
|
+
slots?: {
|
|
12
|
+
listHeader?: any;
|
|
13
|
+
listItem?: any;
|
|
14
|
+
listEmpty?: any;
|
|
15
|
+
listFooter?: any;
|
|
16
|
+
};
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
interface SuggestionListState {
|
|
20
|
+
selectedItem: SuggestionItem | null;
|
|
21
|
+
}
|
|
22
|
+
export declare class SuggestionList extends Component<SuggestionListProps> {
|
|
23
|
+
static props: {
|
|
24
|
+
items: {
|
|
25
|
+
type: ArrayConstructor;
|
|
26
|
+
};
|
|
27
|
+
onSelect: {
|
|
28
|
+
type: FunctionConstructor;
|
|
29
|
+
};
|
|
30
|
+
slots: {
|
|
31
|
+
type: ObjectConstructor;
|
|
32
|
+
optional: boolean;
|
|
33
|
+
};
|
|
34
|
+
className: {
|
|
35
|
+
type: StringConstructor;
|
|
36
|
+
optional: boolean;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
static template: string;
|
|
40
|
+
state: SuggestionListState;
|
|
41
|
+
listRef: {
|
|
42
|
+
el: HTMLElement;
|
|
43
|
+
};
|
|
44
|
+
onSelect(ev: MouseEvent, item: SuggestionItem): void;
|
|
45
|
+
setup(): void;
|
|
46
|
+
onKeyDown: (event: KeyboardEvent) => void;
|
|
47
|
+
upHandler(): void;
|
|
48
|
+
downHandler(): void;
|
|
49
|
+
enterHandler(event: KeyboardEvent): void;
|
|
50
|
+
scrollIntoView(): void;
|
|
51
|
+
}
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Component, xml, useState, useEffect, useRef } from '@odoo/owl';
|
|
2
|
+
import { classNames } from '../../utils/classNames';
|
|
3
|
+
export class SuggestionList extends Component {
|
|
4
|
+
constructor() {
|
|
5
|
+
super(...arguments);
|
|
6
|
+
this.state = useState({
|
|
7
|
+
selectedItem: null,
|
|
8
|
+
});
|
|
9
|
+
this.listRef = useRef('list');
|
|
10
|
+
this.onKeyDown = (event) => {
|
|
11
|
+
if (event.key === 'ArrowUp') {
|
|
12
|
+
this.upHandler();
|
|
13
|
+
event.preventDefault();
|
|
14
|
+
event.stopImmediatePropagation();
|
|
15
|
+
}
|
|
16
|
+
else if (event.key === 'ArrowDown') {
|
|
17
|
+
this.downHandler();
|
|
18
|
+
event.preventDefault();
|
|
19
|
+
event.stopImmediatePropagation();
|
|
20
|
+
}
|
|
21
|
+
else if (event.key === 'Enter') {
|
|
22
|
+
this.enterHandler(event);
|
|
23
|
+
event.preventDefault();
|
|
24
|
+
event.stopImmediatePropagation();
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
onSelect(ev, item) {
|
|
29
|
+
!item.disabled && this.props.onSelect?.(ev, item);
|
|
30
|
+
}
|
|
31
|
+
setup() {
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const selectedItem = this.props.items.find((item) => !item.disabled) ?? null;
|
|
34
|
+
if (this.state.selectedItem?.value !== selectedItem?.value) {
|
|
35
|
+
this.state.selectedItem = selectedItem;
|
|
36
|
+
}
|
|
37
|
+
}, () => [this.props.items]);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
window.addEventListener('keydown', this.onKeyDown, true);
|
|
40
|
+
return () => {
|
|
41
|
+
window.removeEventListener('keydown', this.onKeyDown, true);
|
|
42
|
+
};
|
|
43
|
+
}, () => [this.props.items]);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
this.scrollIntoView();
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
upHandler() {
|
|
49
|
+
const { items } = this.props;
|
|
50
|
+
const { selectedItem } = this.state;
|
|
51
|
+
const selectableItems = items.filter((item) => !item.disabled);
|
|
52
|
+
if (!selectableItems.length) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const selectedIndex = selectableItems.indexOf(selectedItem);
|
|
56
|
+
const newIndex = (selectedIndex - 1 + selectableItems.length) % selectableItems.length;
|
|
57
|
+
this.state.selectedItem = selectableItems[newIndex];
|
|
58
|
+
}
|
|
59
|
+
downHandler() {
|
|
60
|
+
const { items } = this.props;
|
|
61
|
+
const { selectedItem } = this.state;
|
|
62
|
+
const selectableItems = items.filter((item) => !item.disabled);
|
|
63
|
+
if (!selectableItems.length) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const selectedIndex = selectableItems.indexOf(selectedItem);
|
|
67
|
+
const newIndex = (selectedIndex + 1) % selectableItems.length;
|
|
68
|
+
this.state.selectedItem = selectableItems[newIndex];
|
|
69
|
+
}
|
|
70
|
+
enterHandler(event) {
|
|
71
|
+
if (this.state.selectedItem) {
|
|
72
|
+
this.props.onSelect(event, this.state.selectedItem);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
scrollIntoView() {
|
|
76
|
+
if (!this.listRef.el || !this.state.selectedItem) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const selectedItemEl = this.listRef.el.querySelector(`[data-value="${this.state.selectedItem.value}"]`);
|
|
80
|
+
if (selectedItemEl) {
|
|
81
|
+
const listRect = this.listRef.el.getBoundingClientRect();
|
|
82
|
+
const itemRect = selectedItemEl.getBoundingClientRect();
|
|
83
|
+
const listStyle = window.getComputedStyle(this.listRef.el);
|
|
84
|
+
const paddingTop = parseFloat(listStyle.paddingTop);
|
|
85
|
+
const paddingBottom = parseFloat(listStyle.paddingBottom);
|
|
86
|
+
if (itemRect.bottom > listRect.bottom - paddingBottom) {
|
|
87
|
+
this.listRef.el.scrollTop += itemRect.bottom - (listRect.bottom - paddingBottom);
|
|
88
|
+
}
|
|
89
|
+
else if (itemRect.top < listRect.top + paddingTop) {
|
|
90
|
+
this.listRef.el.scrollTop -= listRect.top + paddingTop - itemRect.top;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
SuggestionList.props = {
|
|
96
|
+
items: { type: Array },
|
|
97
|
+
onSelect: { type: Function },
|
|
98
|
+
slots: { type: Object, optional: true },
|
|
99
|
+
className: { type: String, optional: true },
|
|
100
|
+
};
|
|
101
|
+
SuggestionList.template = xml `
|
|
102
|
+
<div class="${classNames('&suggestion-list')}" t-att-class="props.className" t-on-mousedown="(event) => event.preventDefault()" t-ref="list">
|
|
103
|
+
<t t-slot="listHeader"/>
|
|
104
|
+
|
|
105
|
+
<t t-if="props.items.length">
|
|
106
|
+
<t t-foreach="props.items" t-as="item" t-key="item.value">
|
|
107
|
+
<div
|
|
108
|
+
t-att-class="{
|
|
109
|
+
'is-selected': item.value === state.selectedItem?.value,
|
|
110
|
+
'is-disabled': item.disabled,
|
|
111
|
+
}"
|
|
112
|
+
t-on-click="(ev) => this.onSelect(ev, item)"
|
|
113
|
+
t-att-data-value="item.value"
|
|
114
|
+
>
|
|
115
|
+
<t t-slot="listItem" item="item">
|
|
116
|
+
<t t-esc="item.label" />
|
|
117
|
+
</t>
|
|
118
|
+
</div>
|
|
119
|
+
</t>
|
|
120
|
+
</t>
|
|
121
|
+
|
|
122
|
+
<t t-else="">
|
|
123
|
+
<div class="is-empty">
|
|
124
|
+
<t t-slot="listEmpty" item="item">
|
|
125
|
+
没有匹配项
|
|
126
|
+
</t>
|
|
127
|
+
</div>
|
|
128
|
+
</t>
|
|
129
|
+
|
|
130
|
+
<t t-slot="listFooter"/>
|
|
131
|
+
</div>
|
|
132
|
+
`;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
.ott-tag-input {
|
|
2
|
+
position: relative;
|
|
3
|
+
background-color: white;
|
|
4
|
+
}
|
|
5
|
+
.ott-tag-input .ProseMirror {
|
|
6
|
+
padding: 8px;
|
|
7
|
+
border: 1px solid rgb(var(--ott-border-color));
|
|
8
|
+
border-radius: 4px;
|
|
9
|
+
}
|
|
10
|
+
.ott-tag-input .ProseMirror:focus {
|
|
11
|
+
outline: none;
|
|
12
|
+
border-color: rgba(var(--ott-primary-color), 0.8);
|
|
13
|
+
box-shadow: 0 0 0 2px rgba(var(--ott-primary-color), 0.2);
|
|
14
|
+
}
|
|
15
|
+
.ott-tag-input .ProseMirror p {
|
|
16
|
+
margin: 0;
|
|
17
|
+
}
|
|
18
|
+
.ott-tag-input .ProseMirror p.is-editor-empty:first-child::before {
|
|
19
|
+
content: attr(data-placeholder);
|
|
20
|
+
float: left;
|
|
21
|
+
color: rgb(var(--ott-placeholder-color));
|
|
22
|
+
pointer-events: none;
|
|
23
|
+
height: 0;
|
|
24
|
+
}
|
|
25
|
+
.ott-tag-input .tag-wrapper {
|
|
26
|
+
display: inline-flex;
|
|
27
|
+
padding: 0 2px;
|
|
28
|
+
align-items: center;
|
|
29
|
+
}
|
|
30
|
+
.ott-tag-input .tag-wrapper .tag {
|
|
31
|
+
min-height: 20px;
|
|
32
|
+
display: inline-flex;
|
|
33
|
+
align-items: center;
|
|
34
|
+
background-color: rgb(var(--ott-primary-color));
|
|
35
|
+
border: none;
|
|
36
|
+
border-radius: 3px;
|
|
37
|
+
padding: 2px 8px;
|
|
38
|
+
color: white;
|
|
39
|
+
white-space: nowrap;
|
|
40
|
+
transition: all 0.2s;
|
|
41
|
+
font-size: 12px;
|
|
42
|
+
box-sizing: border-box;
|
|
43
|
+
line-height: 1;
|
|
44
|
+
}
|
|
45
|
+
.ott-tag-input .tag-wrapper .tag:hover {
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
}
|
|
48
|
+
.ott-tag-input .tag-wrapper .tag .close-button {
|
|
49
|
+
margin-left: 2px;
|
|
50
|
+
border: none;
|
|
51
|
+
background: none;
|
|
52
|
+
cursor: pointer;
|
|
53
|
+
padding: 0;
|
|
54
|
+
display: inline-flex;
|
|
55
|
+
align-items: center;
|
|
56
|
+
justify-content: center;
|
|
57
|
+
color: white;
|
|
58
|
+
border-radius: 50%;
|
|
59
|
+
width: 14px;
|
|
60
|
+
height: 14px;
|
|
61
|
+
transition: all 0.2s;
|
|
62
|
+
}
|
|
63
|
+
.ott-tag-input .tag-wrapper .tag .close-button:hover {
|
|
64
|
+
background-color: rgba(0, 0, 0, 0.2);
|
|
65
|
+
color: white;
|
|
66
|
+
}
|
|
67
|
+
.ott-tag-input .ott-suggestion-list-wrapper {
|
|
68
|
+
position: absolute;
|
|
69
|
+
z-index: 10;
|
|
70
|
+
left: 0;
|
|
71
|
+
right: 0;
|
|
72
|
+
}
|
|
73
|
+
.ott-tag-input .ott-suggestion-list {
|
|
74
|
+
background: white;
|
|
75
|
+
max-height: 200px;
|
|
76
|
+
border: 1px solid rgba(var(--ott-primary-color), 0.4);
|
|
77
|
+
border-radius: 4px;
|
|
78
|
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
|
79
|
+
overflow: auto;
|
|
80
|
+
padding: 3px;
|
|
81
|
+
}
|
|
82
|
+
.ott-tag-input .ott-suggestion-list div {
|
|
83
|
+
padding: 3px 8px;
|
|
84
|
+
cursor: pointer;
|
|
85
|
+
}
|
|
86
|
+
.ott-tag-input .ott-suggestion-list div.is-selected, .ott-tag-input .ott-suggestion-list div:hover {
|
|
87
|
+
background-color: rgba(var(--ott-primary-color), 0.1);
|
|
88
|
+
color: rgb(var(--ott-primary-color));
|
|
89
|
+
}
|
|
90
|
+
.ott-tag-input .ott-suggestion-list div.is-empty {
|
|
91
|
+
color: rgb(var(--ott-text-color-secondary));
|
|
92
|
+
cursor: default;
|
|
93
|
+
}
|
|
94
|
+
.ott-tag-input .ott-suggestion-list div.is-disabled {
|
|
95
|
+
color: rgb(var(--ott-text-color-secondary));
|
|
96
|
+
cursor: not-allowed;
|
|
97
|
+
}
|
|
98
|
+
.ott-tag-input .ott-suggestion-list div.is-disabled.is-selected, .ott-tag-input .ott-suggestion-list div.is-disabled:hover {
|
|
99
|
+
background-color: transparent;
|
|
100
|
+
color: rgb(var(--ott-text-color-secondary));
|
|
101
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { Component } from '@odoo/owl';
|
|
2
|
+
import { Editor } from '@tiptap/core';
|
|
3
|
+
import { SuggestionList, SuggestionItem } from './SuggestionList';
|
|
4
|
+
import './TagInput.css';
|
|
5
|
+
export interface TagAttributes {
|
|
6
|
+
value: string;
|
|
7
|
+
label?: string;
|
|
8
|
+
group?: string;
|
|
9
|
+
[key: string]: any;
|
|
10
|
+
}
|
|
11
|
+
export interface TagNode {
|
|
12
|
+
node: any;
|
|
13
|
+
pos: number;
|
|
14
|
+
}
|
|
15
|
+
interface SuggestionState {
|
|
16
|
+
visible: boolean;
|
|
17
|
+
range: {
|
|
18
|
+
from: number;
|
|
19
|
+
to: number;
|
|
20
|
+
};
|
|
21
|
+
items: SuggestionItem[];
|
|
22
|
+
style: string;
|
|
23
|
+
}
|
|
24
|
+
interface TagInputState {
|
|
25
|
+
editor: Editor | null;
|
|
26
|
+
suggestion: SuggestionState;
|
|
27
|
+
}
|
|
28
|
+
interface TagInputProps {
|
|
29
|
+
ref?: (component: TagInput) => void;
|
|
30
|
+
placeholder?: string;
|
|
31
|
+
slots?: {
|
|
32
|
+
listHeader?: any;
|
|
33
|
+
listItem?: any;
|
|
34
|
+
listEmpty?: any;
|
|
35
|
+
listFooter?: any;
|
|
36
|
+
};
|
|
37
|
+
suggestionListClassName?: string;
|
|
38
|
+
onSuggestionSelect?: (event: MouseEvent, item: SuggestionItem) => void;
|
|
39
|
+
items?: (params: {
|
|
40
|
+
query: string;
|
|
41
|
+
}) => SuggestionItem[];
|
|
42
|
+
onChange?: (content: any) => void;
|
|
43
|
+
readonly?: boolean;
|
|
44
|
+
onTagClick?: (index: number, attrs: TagAttributes) => void;
|
|
45
|
+
char?: string;
|
|
46
|
+
}
|
|
47
|
+
export declare class TagInput extends Component<TagInputProps> {
|
|
48
|
+
static components: {
|
|
49
|
+
SuggestionList: typeof SuggestionList;
|
|
50
|
+
};
|
|
51
|
+
static props: {
|
|
52
|
+
className: {
|
|
53
|
+
type: StringConstructor;
|
|
54
|
+
optional: boolean;
|
|
55
|
+
};
|
|
56
|
+
ref: {
|
|
57
|
+
type: FunctionConstructor;
|
|
58
|
+
optional: boolean;
|
|
59
|
+
};
|
|
60
|
+
placeholder: {
|
|
61
|
+
type: StringConstructor;
|
|
62
|
+
optional: boolean;
|
|
63
|
+
};
|
|
64
|
+
slots: {
|
|
65
|
+
type: ObjectConstructor;
|
|
66
|
+
optional: boolean;
|
|
67
|
+
};
|
|
68
|
+
suggestionListClassName: {
|
|
69
|
+
type: StringConstructor;
|
|
70
|
+
optional: boolean;
|
|
71
|
+
};
|
|
72
|
+
onSuggestionSelect: {
|
|
73
|
+
type: FunctionConstructor;
|
|
74
|
+
optional: boolean;
|
|
75
|
+
};
|
|
76
|
+
items: {
|
|
77
|
+
type: FunctionConstructor;
|
|
78
|
+
optional: boolean;
|
|
79
|
+
};
|
|
80
|
+
onChange: {
|
|
81
|
+
type: FunctionConstructor;
|
|
82
|
+
optional: boolean;
|
|
83
|
+
};
|
|
84
|
+
readonly: {
|
|
85
|
+
type: BooleanConstructor;
|
|
86
|
+
optional: boolean;
|
|
87
|
+
};
|
|
88
|
+
onTagClick: {
|
|
89
|
+
type: FunctionConstructor;
|
|
90
|
+
optional: boolean;
|
|
91
|
+
};
|
|
92
|
+
char: {
|
|
93
|
+
type: StringConstructor;
|
|
94
|
+
optional: boolean;
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
static template: string;
|
|
98
|
+
static defaultProps: {
|
|
99
|
+
items: () => any[];
|
|
100
|
+
readonly: boolean;
|
|
101
|
+
char: string;
|
|
102
|
+
};
|
|
103
|
+
editorRef: {
|
|
104
|
+
el: HTMLElement;
|
|
105
|
+
};
|
|
106
|
+
state: TagInputState;
|
|
107
|
+
onSuggestionSelect(event: MouseEvent, item: SuggestionItem): void;
|
|
108
|
+
updateSuggestion(props: any, options?: Partial<SuggestionState>): void;
|
|
109
|
+
/**
|
|
110
|
+
* Serializes the current content of the editor.
|
|
111
|
+
* @returns {object} The content as a JSON object.
|
|
112
|
+
*/
|
|
113
|
+
getContent(): any;
|
|
114
|
+
/**
|
|
115
|
+
* Deserializes content into the editor.
|
|
116
|
+
* @param {object} content - The content to set, as a JSON object.
|
|
117
|
+
*/
|
|
118
|
+
setContent(content: any): void;
|
|
119
|
+
/**
|
|
120
|
+
* Adds a tag to the editor.
|
|
121
|
+
* @param {object} range - The range of the tag
|
|
122
|
+
* @param {number} range.from - The start position of the tag.
|
|
123
|
+
* @param {number} range.to - The end position of the tag.
|
|
124
|
+
* @param {object} props - The properties of the tag.
|
|
125
|
+
*/
|
|
126
|
+
addTag(range: {
|
|
127
|
+
from: number;
|
|
128
|
+
to: number;
|
|
129
|
+
}, props: TagAttributes): void;
|
|
130
|
+
/**
|
|
131
|
+
* Removes a tag from the editor by its index.
|
|
132
|
+
* @param {number} index - The index of the tag to remove.
|
|
133
|
+
*/
|
|
134
|
+
removeTag(index: number): void;
|
|
135
|
+
/**
|
|
136
|
+
* Replaces a tag at a specific index with a new tag.
|
|
137
|
+
* @param {number} index - The index of the tag to replace.
|
|
138
|
+
* @param {object} props - The properties of the new tag.
|
|
139
|
+
*/
|
|
140
|
+
replaceTag(index: number, props: TagAttributes): void;
|
|
141
|
+
/**
|
|
142
|
+
* Gets all tag nodes from the editor.
|
|
143
|
+
* @returns {Array} An array of tag nodes with their positions.
|
|
144
|
+
*/
|
|
145
|
+
getTags(): TagNode[];
|
|
146
|
+
renderEditor(): void;
|
|
147
|
+
destroyEditor(): void;
|
|
148
|
+
setup(): void;
|
|
149
|
+
}
|
|
150
|
+
export {};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { Component, xml, useRef, onMounted, useEffect, useState, markRaw } from '@odoo/owl';
|
|
2
|
+
import { classNames } from '../../utils/classNames';
|
|
3
|
+
import { Editor } from '@tiptap/core';
|
|
4
|
+
import Document from '@tiptap/extension-document';
|
|
5
|
+
import Text from '@tiptap/extension-text';
|
|
6
|
+
import Paragraph from '@tiptap/extension-paragraph';
|
|
7
|
+
import History from '@tiptap/extension-history';
|
|
8
|
+
import Placeholder from '@tiptap/extension-placeholder';
|
|
9
|
+
import { SuggestionList } from './SuggestionList';
|
|
10
|
+
import { TagNode } from './TagNode';
|
|
11
|
+
import { SuggestionPlugin, exitSuggestionPlugin } from './suggestion';
|
|
12
|
+
import './TagInput.css';
|
|
13
|
+
export class TagInput extends Component {
|
|
14
|
+
constructor() {
|
|
15
|
+
super(...arguments);
|
|
16
|
+
this.editorRef = useRef('editor');
|
|
17
|
+
this.state = useState({
|
|
18
|
+
editor: null,
|
|
19
|
+
suggestion: {
|
|
20
|
+
visible: false,
|
|
21
|
+
range: {
|
|
22
|
+
from: 0,
|
|
23
|
+
to: 0,
|
|
24
|
+
},
|
|
25
|
+
items: [],
|
|
26
|
+
style: '',
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
onSuggestionSelect(event, item) {
|
|
31
|
+
this.props.onSuggestionSelect?.(event, item);
|
|
32
|
+
if (!event.cancelBubble) {
|
|
33
|
+
this.addTag(this.state.suggestion.range, item);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
updateSuggestion(props, options = {}) {
|
|
37
|
+
const clientRect = props.clientRect();
|
|
38
|
+
const editorRect = this.editorRef.el.getBoundingClientRect();
|
|
39
|
+
const { innerHeight } = window;
|
|
40
|
+
let style = '';
|
|
41
|
+
if (clientRect.bottom + 200 > innerHeight) {
|
|
42
|
+
style = `bottom: ${editorRect.height}px`;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
style = `top: ${editorRect.height}`;
|
|
46
|
+
}
|
|
47
|
+
this.state.suggestion = {
|
|
48
|
+
...this.state.suggestion,
|
|
49
|
+
range: props.range,
|
|
50
|
+
items: props.items,
|
|
51
|
+
style,
|
|
52
|
+
...options,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Serializes the current content of the editor.
|
|
57
|
+
* @returns {object} The content as a JSON object.
|
|
58
|
+
*/
|
|
59
|
+
getContent() {
|
|
60
|
+
return this.state.editor.getJSON();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Deserializes content into the editor.
|
|
64
|
+
* @param {object} content - The content to set, as a JSON object.
|
|
65
|
+
*/
|
|
66
|
+
setContent(content) {
|
|
67
|
+
this.state.editor.commands.setContent(content);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Adds a tag to the editor.
|
|
71
|
+
* @param {object} range - The range of the tag
|
|
72
|
+
* @param {number} range.from - The start position of the tag.
|
|
73
|
+
* @param {number} range.to - The end position of the tag.
|
|
74
|
+
* @param {object} props - The properties of the tag.
|
|
75
|
+
*/
|
|
76
|
+
addTag(range, props) {
|
|
77
|
+
this.state
|
|
78
|
+
.editor.chain()
|
|
79
|
+
.focus()
|
|
80
|
+
.insertContentAt(range, {
|
|
81
|
+
type: 'tag',
|
|
82
|
+
attrs: props,
|
|
83
|
+
})
|
|
84
|
+
.run();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Removes a tag from the editor by its index.
|
|
88
|
+
* @param {number} index - The index of the tag to remove.
|
|
89
|
+
*/
|
|
90
|
+
removeTag(index) {
|
|
91
|
+
const tags = this.getTags();
|
|
92
|
+
const tagToRemove = tags[index];
|
|
93
|
+
if (tagToRemove) {
|
|
94
|
+
const { node, pos } = tagToRemove;
|
|
95
|
+
const from = pos;
|
|
96
|
+
const to = pos + node.nodeSize;
|
|
97
|
+
this.state.editor.chain().focus().deleteRange({ from, to }).run();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Replaces a tag at a specific index with a new tag.
|
|
102
|
+
* @param {number} index - The index of the tag to replace.
|
|
103
|
+
* @param {object} props - The properties of the new tag.
|
|
104
|
+
*/
|
|
105
|
+
replaceTag(index, props) {
|
|
106
|
+
const tags = this.getTags();
|
|
107
|
+
const tagToReplace = tags[index];
|
|
108
|
+
if (tagToReplace) {
|
|
109
|
+
const { node, pos } = tagToReplace;
|
|
110
|
+
const from = pos;
|
|
111
|
+
const to = pos + node.nodeSize;
|
|
112
|
+
this.state
|
|
113
|
+
.editor.chain()
|
|
114
|
+
.focus()
|
|
115
|
+
.insertContentAt({ from, to }, {
|
|
116
|
+
type: 'tag',
|
|
117
|
+
attrs: props,
|
|
118
|
+
})
|
|
119
|
+
.run();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Gets all tag nodes from the editor.
|
|
124
|
+
* @returns {Array} An array of tag nodes with their positions.
|
|
125
|
+
*/
|
|
126
|
+
getTags() {
|
|
127
|
+
const tags = [];
|
|
128
|
+
this.state.editor.state.doc.descendants((node, pos) => {
|
|
129
|
+
if (node.type.name === 'tag') {
|
|
130
|
+
tags.push({ node, pos });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
return tags;
|
|
134
|
+
}
|
|
135
|
+
renderEditor() {
|
|
136
|
+
this.destroyEditor();
|
|
137
|
+
this.state.editor = markRaw(new Editor({
|
|
138
|
+
element: this.editorRef.el,
|
|
139
|
+
editable: !this.props.readonly,
|
|
140
|
+
extensions: [
|
|
141
|
+
Document,
|
|
142
|
+
Text,
|
|
143
|
+
Paragraph,
|
|
144
|
+
History,
|
|
145
|
+
Placeholder.configure({
|
|
146
|
+
placeholder: this.props.placeholder || `请输入文本,按${this.props.char}选择标签...`,
|
|
147
|
+
}),
|
|
148
|
+
TagNode.configure({
|
|
149
|
+
readonly: this.props.readonly,
|
|
150
|
+
onTagClick: (attrs, pos) => {
|
|
151
|
+
if (this.props.onTagClick) {
|
|
152
|
+
const tags = this.getTags();
|
|
153
|
+
const index = tags.findIndex((tag) => tag.pos === pos);
|
|
154
|
+
if (index !== -1) {
|
|
155
|
+
this.props.onTagClick(index, attrs);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
}),
|
|
160
|
+
SuggestionPlugin.configure({
|
|
161
|
+
char: this.props.char,
|
|
162
|
+
items: this.props.items,
|
|
163
|
+
render: () => {
|
|
164
|
+
return {
|
|
165
|
+
onStart: (props) => {
|
|
166
|
+
if (props.text !== this.props.char) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
this.updateSuggestion(props, { visible: true });
|
|
170
|
+
},
|
|
171
|
+
onUpdate: (props) => {
|
|
172
|
+
this.updateSuggestion(props);
|
|
173
|
+
},
|
|
174
|
+
onExit: (props) => {
|
|
175
|
+
this.state.suggestion.visible = false;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
}),
|
|
180
|
+
],
|
|
181
|
+
content: null,
|
|
182
|
+
onBlur: ({ editor }) => {
|
|
183
|
+
this.state.suggestion.visible = false;
|
|
184
|
+
exitSuggestionPlugin(editor.view);
|
|
185
|
+
},
|
|
186
|
+
onUpdate: ({ editor, transaction }) => {
|
|
187
|
+
if (transaction.docChanged) {
|
|
188
|
+
this.props.onChange?.(editor.getJSON());
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
destroyEditor() {
|
|
194
|
+
this.state.editor?.destroy();
|
|
195
|
+
}
|
|
196
|
+
setup() {
|
|
197
|
+
onMounted(() => {
|
|
198
|
+
this.props.ref?.(this);
|
|
199
|
+
});
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (this.state.editor) {
|
|
202
|
+
const content = this.getContent();
|
|
203
|
+
this.destroyEditor();
|
|
204
|
+
this.renderEditor();
|
|
205
|
+
this.setContent(content);
|
|
206
|
+
}
|
|
207
|
+
}, () => [this.props.readonly]);
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
this.renderEditor();
|
|
210
|
+
return () => {
|
|
211
|
+
this.destroyEditor();
|
|
212
|
+
};
|
|
213
|
+
}, () => []);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
TagInput.components = { SuggestionList };
|
|
217
|
+
TagInput.props = {
|
|
218
|
+
className: { type: String, optional: true },
|
|
219
|
+
ref: { type: Function, optional: true },
|
|
220
|
+
placeholder: { type: String, optional: true },
|
|
221
|
+
slots: { type: Object, optional: true },
|
|
222
|
+
suggestionListClassName: { type: String, optional: true },
|
|
223
|
+
onSuggestionSelect: { type: Function, optional: true },
|
|
224
|
+
items: { type: Function, optional: true },
|
|
225
|
+
onChange: { type: Function, optional: true },
|
|
226
|
+
readonly: { type: Boolean, optional: true },
|
|
227
|
+
onTagClick: { type: Function, optional: true },
|
|
228
|
+
char: { type: String, optional: true },
|
|
229
|
+
};
|
|
230
|
+
TagInput.template = xml `
|
|
231
|
+
<div class="${classNames('&tag-input')}" t-att-class="props.className">
|
|
232
|
+
<div t-ref="editor"/>
|
|
233
|
+
<div t-if="state.suggestion.visible" t-att-style="state.suggestion.style" class="ott-suggestion-list-wrapper">
|
|
234
|
+
<SuggestionList
|
|
235
|
+
className="props.suggestionListClassName"
|
|
236
|
+
items="state.suggestion.items"
|
|
237
|
+
onSelect.bind="onSuggestionSelect"
|
|
238
|
+
slots="props.slots"
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
`;
|
|
243
|
+
TagInput.defaultProps = {
|
|
244
|
+
items: () => [],
|
|
245
|
+
readonly: false,
|
|
246
|
+
char: '@',
|
|
247
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Node } from '@tiptap/core';
|
|
2
|
+
import { TagAttributes } from './TagInput';
|
|
3
|
+
interface TagNodeOptions {
|
|
4
|
+
onTagClick: (attrs: TagAttributes, pos: number) => void;
|
|
5
|
+
renderLabel?: (attrs: TagAttributes) => string;
|
|
6
|
+
readonly?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare const TagNode: Node<TagNodeOptions, any>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Node } from '@tiptap/core';
|
|
2
|
+
const closeSVG = `
|
|
3
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14">
|
|
4
|
+
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
5
|
+
</svg>
|
|
6
|
+
`;
|
|
7
|
+
export const TagNode = Node.create({
|
|
8
|
+
name: 'tag',
|
|
9
|
+
group: 'inline',
|
|
10
|
+
inline: true,
|
|
11
|
+
selectable: false,
|
|
12
|
+
atom: true,
|
|
13
|
+
addOptions() {
|
|
14
|
+
return {
|
|
15
|
+
onTagClick: () => { },
|
|
16
|
+
readonly: false, // 默认不只读
|
|
17
|
+
};
|
|
18
|
+
},
|
|
19
|
+
addAttributes() {
|
|
20
|
+
return {
|
|
21
|
+
value: {
|
|
22
|
+
default: null,
|
|
23
|
+
},
|
|
24
|
+
label: {
|
|
25
|
+
default: null,
|
|
26
|
+
},
|
|
27
|
+
group: {
|
|
28
|
+
default: null,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
addNodeView() {
|
|
33
|
+
// We can implement an Owl NodeView here later if complex rendering is needed.
|
|
34
|
+
// For now, renderHTML is sufficient.
|
|
35
|
+
return ({ node, getPos, editor }) => {
|
|
36
|
+
const { readonly } = this.options; // 从 options 中获取当前状态
|
|
37
|
+
console.log(readonly);
|
|
38
|
+
const wrapper = document.createElement('span');
|
|
39
|
+
wrapper.classList.add('tag-wrapper');
|
|
40
|
+
const dom = document.createElement('span');
|
|
41
|
+
// Get attributes directly from the node being rendered
|
|
42
|
+
const { label, ...attrs } = node.attrs;
|
|
43
|
+
// Set data attributes
|
|
44
|
+
Object.entries(attrs).forEach(([key, value]) => {
|
|
45
|
+
if (value !== null) {
|
|
46
|
+
dom.setAttribute(`data-${key}`, value);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
dom.setAttribute('data-type', 'tag');
|
|
50
|
+
dom.classList.add('tag'); // Add a class for styling
|
|
51
|
+
dom.addEventListener('click', () => {
|
|
52
|
+
this.options.onTagClick(node.attrs, getPos());
|
|
53
|
+
});
|
|
54
|
+
const labelSpan = document.createElement('span');
|
|
55
|
+
labelSpan.textContent = label; // Set the visible text
|
|
56
|
+
dom.appendChild(labelSpan);
|
|
57
|
+
if (!readonly) {
|
|
58
|
+
const closeButton = document.createElement('button');
|
|
59
|
+
closeButton.classList.add('close-button');
|
|
60
|
+
closeButton.innerHTML = closeSVG;
|
|
61
|
+
closeButton.addEventListener('click', (e) => {
|
|
62
|
+
e.stopPropagation();
|
|
63
|
+
const pos = getPos();
|
|
64
|
+
editor.view.dispatch(editor.view.state.tr.delete(pos, pos + node.nodeSize));
|
|
65
|
+
editor.view.focus();
|
|
66
|
+
});
|
|
67
|
+
dom.appendChild(closeButton);
|
|
68
|
+
}
|
|
69
|
+
wrapper.appendChild(dom);
|
|
70
|
+
return {
|
|
71
|
+
dom: wrapper,
|
|
72
|
+
update: (updatedNode) => {
|
|
73
|
+
debugger;
|
|
74
|
+
return true;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/core';
|
|
2
|
+
import { SuggestionItem } from './SuggestionList';
|
|
3
|
+
import { PluginKey } from 'prosemirror-state';
|
|
4
|
+
interface SuggestionPluginOptions {
|
|
5
|
+
char?: string;
|
|
6
|
+
allowedPrefixes?: string[] | null;
|
|
7
|
+
items?: (params: {
|
|
8
|
+
query: string;
|
|
9
|
+
}) => SuggestionItem[];
|
|
10
|
+
command?: (props: any) => void;
|
|
11
|
+
render?: () => any;
|
|
12
|
+
}
|
|
13
|
+
export declare const MySuggestionPluginKey: PluginKey<any>;
|
|
14
|
+
export declare const exitSuggestionPlugin: (view: any) => void;
|
|
15
|
+
export declare const SuggestionPlugin: Extension<SuggestionPluginOptions, any>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/core';
|
|
2
|
+
import { Suggestion } from '@tiptap/suggestion';
|
|
3
|
+
import { PluginKey } from 'prosemirror-state'; // optional, if you need to create a custom key
|
|
4
|
+
import { exitSuggestion } from '@tiptap/suggestion';
|
|
5
|
+
export const MySuggestionPluginKey = new PluginKey('my-suggestions'); // or use the default 'suggestion'
|
|
6
|
+
export const exitSuggestionPlugin = (view) => {
|
|
7
|
+
exitSuggestion(view, MySuggestionPluginKey);
|
|
8
|
+
};
|
|
9
|
+
export const SuggestionPlugin = Extension.create({
|
|
10
|
+
name: 'suggestionPlugin',
|
|
11
|
+
addOptions() {
|
|
12
|
+
return {
|
|
13
|
+
allowedPrefixes: null, // Default Tiptap behavior
|
|
14
|
+
command: () => { },
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
addProseMirrorPlugins() {
|
|
18
|
+
return [
|
|
19
|
+
Suggestion({
|
|
20
|
+
pluginKey: MySuggestionPluginKey,
|
|
21
|
+
editor: this.editor,
|
|
22
|
+
...this.options,
|
|
23
|
+
}),
|
|
24
|
+
];
|
|
25
|
+
},
|
|
26
|
+
});
|
package/es/index.css
ADDED
package/es/index.d.ts
ADDED
package/es/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import _classNames from 'classnames';
|
|
2
|
+
const PREFIX = 'ott';
|
|
3
|
+
/**
|
|
4
|
+
* 自定义className合并函数,用于给带&的className添加前缀
|
|
5
|
+
* @param names - 需要合并的className字符串
|
|
6
|
+
* @returns 合并后的className字符串
|
|
7
|
+
*/
|
|
8
|
+
export const classNames = (...names) => {
|
|
9
|
+
const result = _classNames(...names);
|
|
10
|
+
if (result === '') {
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
13
|
+
return result
|
|
14
|
+
.split(' ')
|
|
15
|
+
.map((name) => {
|
|
16
|
+
if (name.startsWith('&')) {
|
|
17
|
+
return `${PREFIX}-${name.slice(1)}`;
|
|
18
|
+
}
|
|
19
|
+
return name;
|
|
20
|
+
})
|
|
21
|
+
.join(' ');
|
|
22
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "owl-tiptap",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "tiptap used in odoo owl2",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "vite",
|
|
8
|
+
"clean": "gulp clean",
|
|
9
|
+
"build": "gulp build",
|
|
10
|
+
"eslint": "eslint ./src/. ./test/. --ext .js,.ts --fix"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/birdix-organiza/owl-tiptap"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"es"
|
|
19
|
+
],
|
|
20
|
+
"keywords": [
|
|
21
|
+
"owl"
|
|
22
|
+
],
|
|
23
|
+
"author": "birdix",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@tiptap/core": "^3.2.0",
|
|
27
|
+
"@tiptap/extension-document": "^3.13.0",
|
|
28
|
+
"@tiptap/extension-history": "^3.13.0",
|
|
29
|
+
"@tiptap/extension-mention": "^3.2.0",
|
|
30
|
+
"@tiptap/extension-paragraph": "^3.13.0",
|
|
31
|
+
"@tiptap/extension-placeholder": "^3.2.0",
|
|
32
|
+
"@tiptap/extension-text": "^3.13.0",
|
|
33
|
+
"@tiptap/suggestion": "^3.13.0",
|
|
34
|
+
"classnames": "^2.3.2"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@odoo/owl": "~2.4.1"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@eslint/js": "^9.22.0",
|
|
41
|
+
"@gulp-plugin/alias": "^2.2.2",
|
|
42
|
+
"@odoo/owl": "^2.4.1",
|
|
43
|
+
"@stylistic/eslint-plugin": "^4.2.0",
|
|
44
|
+
"@stylistic/eslint-plugin-ts": "^4.2.0",
|
|
45
|
+
"@typescript-eslint/parser": "^8.29.1",
|
|
46
|
+
"@vitejs/plugin-legacy": "^6.0.2",
|
|
47
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
48
|
+
"copyfiles": "^2.4.1",
|
|
49
|
+
"eslint": "^9.22.0",
|
|
50
|
+
"glob": "^11.0.1",
|
|
51
|
+
"globals": "^16.0.0",
|
|
52
|
+
"gulp": "^5.0.0",
|
|
53
|
+
"gulp-clean": "^0.4.0",
|
|
54
|
+
"gulp-replace": "^1.1.4",
|
|
55
|
+
"gulp-sass": "^6.0.1",
|
|
56
|
+
"gulp-typescript": "^6.0.0-alpha.1",
|
|
57
|
+
"merge2": "^1.4.1",
|
|
58
|
+
"prettier": "^3.5.3",
|
|
59
|
+
"rimraf": "^6.0.1",
|
|
60
|
+
"sass": "^1.86.3",
|
|
61
|
+
"sass-embedded": "^1.86.0",
|
|
62
|
+
"tsccss": "^1.0.0",
|
|
63
|
+
"typescript": "^5.8.2",
|
|
64
|
+
"vite": "^6.2.2",
|
|
65
|
+
"vite-plugin-eslint2": "^5.0.3"
|
|
66
|
+
}
|
|
67
|
+
}
|