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 ADDED
@@ -0,0 +1,7 @@
1
+ # Owl Tiptap 组件库
2
+
3
+ 这是一个基于 Odoo Owl 和 Tiptap 构建的富文本编辑器组件库。
4
+
5
+ ## 可用组件
6
+
7
+ - [TagInput](./src/components/tag-input/README.md): 一个支持标签输入的组件。
@@ -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
@@ -0,0 +1,6 @@
1
+ :root {
2
+ --ott-primary-color: 22, 119, 255;
3
+ --ott-border-color: 204, 204, 204;
4
+ --ott-placeholder-color: 173, 181, 189;
5
+ --ott-text-color-secondary: 136, 136, 136;
6
+ }
package/es/index.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import './index.css';
2
+ import { TagInput } from './components/tag-input/TagInput';
3
+ export { TagInput };
package/es/index.js ADDED
@@ -0,0 +1,3 @@
1
+ import './index.css';
2
+ import { TagInput } from './components/tag-input/TagInput';
3
+ export { TagInput };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * 自定义className合并函数,用于给带&的className添加前缀
3
+ * @param names - 需要合并的className字符串
4
+ * @returns 合并后的className字符串
5
+ */
6
+ export declare const classNames: (...names: string[]) => string;
@@ -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
+ }