diff --git a/qortal-ui-plugins/package.json b/qortal-ui-plugins/package.json index b117f3f3..31a7523d 100644 --- a/qortal-ui-plugins/package.json +++ b/qortal-ui-plugins/package.json @@ -20,10 +20,26 @@ "@lit-labs/motion": "^1.0.3", "@material/mwc-list": "0.27.0", "@material/mwc-select": "0.27.0", + "@tiptap/core": "^2.0.0-beta.209", + "@tiptap/extension-image": "^2.0.0-beta.209", + "@tiptap/extension-placeholder": "^2.0.0-beta.209", + "@tiptap/extension-underline": "^2.0.0-beta.209", + "@tiptap/html": "^2.0.0-beta.209", + "@tiptap/starter-kit": "^2.0.0-beta.209", "asmcrypto.js": "2.3.2", "compressorjs": "^1.1.1", "emoji-picker-js": "https://github.com/Qortal/emoji-picker-js", "localforage": "^1.10.0", + "prosemirror-commands": "^1.5.0", + "prosemirror-dropcursor": "^1.6.1", + "prosemirror-gapcursor": "^1.3.1", + "prosemirror-history": "^1.3.0", + "prosemirror-keymap": "^1.2.0", + "prosemirror-model": "^1.18.3", + "prosemirror-schema-list": "^1.2.2", + "prosemirror-state": "^1.4.2", + "prosemirror-transform": "^1.7.0", + "prosemirror-view": "^1.29.1", "short-unique-id": "^4.4.4" }, "devDependencies": { @@ -47,8 +63,6 @@ "@polymer/paper-slider": "3.0.1", "@polymer/paper-spinner": "3.0.2", "@polymer/paper-tooltip": "3.0.1", - "@vaadin/horizontal-layout": "23.3.2", - "@vaadin/tabs": "23.3.2", "@rollup/plugin-alias": "4.0.2", "@rollup/plugin-babel": "6.0.3", "@rollup/plugin-commonjs": "24.0.0", @@ -58,7 +72,9 @@ "@vaadin/avatar": "23.3.2", "@vaadin/button": "23.3.2", "@vaadin/grid": "23.3.2", + "@vaadin/horizontal-layout": "23.3.2", "@vaadin/icons": "23.3.2", + "@vaadin/tabs": "23.3.2", "@vaadin/tooltip": "23.3.2", "epml": "0.3.3", "file-saver": "2.0.5", diff --git a/qortal-ui-plugins/plugins/core/components/ChatPage.js b/qortal-ui-plugins/plugins/core/components/ChatPage.js index 39fb2a50..e117b082 100644 --- a/qortal-ui-plugins/plugins/core/components/ChatPage.js +++ b/qortal-ui-plugins/plugins/core/components/ChatPage.js @@ -4,6 +4,12 @@ import {animate} from '@lit-labs/motion'; import { Epml } from '../../../epml.js'; import { use, get, translate, registerTranslateConfig } from 'lit-translate'; import { chatStyles } from './ChatScroller-css.js' +import { generateHTML } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' +import Underline from '@tiptap/extension-underline'; +import Placeholder from '@tiptap/extension-placeholder' +import {unsafeHTML} from 'lit/directives/unsafe-html.js'; +import { Editor, Extension } from '@tiptap/core' // import localForage from "localforage"; registerTranslateConfig({ @@ -72,8 +78,6 @@ class ChatPage extends LitElement { iframeHeight: { type: Number }, imageFile: { type: Object }, isUploadingImage: { type: Boolean }, - chatEditor: { type: Object }, - chatEditorNewChat: { type: Object }, userLanguage: { type: String }, lastMessageRefVisible: { type: Boolean }, isLoadingOldMessages: { type: Boolean }, @@ -91,7 +95,9 @@ class ChatPage extends LitElement { userFoundModalOpen: { type: Boolean }, webWorker: { type: Object }, webWorkerImage: { type: Object }, - myTrimmedMeassage: { type: String } + myTrimmedMeassage: { type: String }, + editor: {type: Object}, + currentEditor: {type: String} } } @@ -299,6 +305,7 @@ class ChatPage extends LitElement { justify-content: center; min-height: 60px; max-height: 100%; + overflow: hidden; } .chat-text-area .typing-area { @@ -348,6 +355,10 @@ class ChatPage extends LitElement { gap: 5px; width: 100%; } + .repliedTo-message p { + margin: 0px; + padding: 0px; + } .reply-icon { width: 20px; @@ -858,6 +869,7 @@ class ChatPage extends LitElement { } this.webWorker = null; this.webWorkerImage = null; + this.currentEditor = '_chatEditorDOM' } _toggle(value) { @@ -922,7 +934,11 @@ class ChatPage extends LitElement {

${this.repliedToMessageObj.senderName ? this.repliedToMessageObj.senderName : this.repliedToMessageObj.sender}

-

${this.repliedToMessageObj.message}

+ ${unsafeHTML(generateHTML(this.repliedToMessageObj.message, [ + StarterKit, + Underline + // other extensions … + ]))}

${translate("chatpage.cchange25")}

-

${this.editedMessageObj.message}

+ ${unsafeHTML(generateHTML(this.editedMessageObj.message, [ + StarterKit, + Underline + // other extensions … + ]))}
this.setChatEditor(editor)} - .chatEditor=${this.chatEditor} .imageFile=${this.imageFile} .insertImage=${this.insertImage} .editedMessageObj=${this.editedMessageObj} ?isLoading=${this.isLoading} ?isLoadingMessages=${this.isLoadingMessages} - ?isEditMessageOpen=${this.isEditMessageOpen}> + ?isEditMessageOpen=${this.isEditMessageOpen} + .editor=${this.editor} + .updatePlaceholder=${(editor, value)=> this.updatePlaceholder(editor, value)} + id="_chatEditorDOM" + > @@ -985,10 +1007,9 @@ class ChatPage extends LitElement { `: ''} { - this.chatEditorNewChat.resetValue(); this.removeImage(); }} - style=${(this.imageFile && !this.isUploadingImage) ? "display: block" : "display: none"}> + style=${(this.imageFile && !this.isUploadingImage) ? "visibility:visible;z-index:50" : "visibility: hidden;z-index:-100"}>
${this.imageFile && html` @@ -1000,20 +1021,20 @@ class ChatPage extends LitElement { ?hasGlobalEvents=${false} placeholder=${this.chatEditorPlaceholder} ._sendMessage=${this._sendMessage} - .setChatEditor=${(editor)=> this.setChatEditorNewChat(editor)} - .chatEditor=${this.chatEditorNewChat} .imageFile=${this.imageFile} .insertImage=${this.insertImage} .editedMessageObj=${this.editedMessageObj} ?isLoading=${this.isLoading} ?isLoadingMessages=${this.isLoadingMessages} id="chatTextCaption" + .editor=${this.editorImage} + .updatePlaceholder=${(editor, value)=> this.updatePlaceholder(editor, value)} >
`} @@ -501,7 +516,8 @@ class MessageTemplate extends LitElement { id="messageContent" class="message" style=${(image && replacedMessage !== "") &&"margin-top: 15px;"}> - ${unsafeHTML(this.emojiPicker.parse(replacedMessage))} + + ${unsafeHTML(messageVersion2)}
@@ -783,7 +796,6 @@ class ChatMenu extends LitElement { return } this.setEditedMessageObj(this.originalMessage); - this.focusChatEditor(); }}>
diff --git a/qortal-ui-plugins/plugins/core/components/ChatTextEditor copy.js b/qortal-ui-plugins/plugins/core/components/ChatTextEditor copy.js new file mode 100644 index 00000000..dfc572bc --- /dev/null +++ b/qortal-ui-plugins/plugins/core/components/ChatTextEditor copy.js @@ -0,0 +1,828 @@ +import { LitElement, html, css } from "lit"; +import { get } from 'lit-translate'; +import { escape, unescape } from 'html-escaper'; +import { EmojiPicker } from 'emoji-picker-js'; +import { inputKeyCodes } from '../../utils/keyCodes.js'; +import { Epml } from '../../../epml.js'; + +const parentEpml = new Epml({ type: 'WINDOW', source: window.parent }); +class ChatTextEditor extends LitElement { + static get properties() { + return { + isLoading: { type: Boolean }, + isLoadingMessages: { type: Boolean }, + _sendMessage: { attribute: false }, + placeholder: { type: String }, + imageFile: { type: Object }, + insertImage: { attribute: false }, + iframeHeight: { type: Number }, + editedMessageObj: { type: Object }, + chatEditor: { type: Object }, + setChatEditor: { attribute: false }, + iframeId: { type: String }, + hasGlobalEvents: { type: Boolean }, + chatMessageSize: { type: Number }, + isEditMessageOpen: { type: Boolean }, + theme: { + type: String, + reflect: true + } + } + } + + static get styles() { + return css` + :host { + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: auto; + overflow-y: hidden; + width: 100%; + } + .chatbar-container { + width: 100%; + display: flex; + height: auto; + overflow: hidden; + } + + .chatbar-caption { + border-bottom: 2px solid var(--mdc-theme-primary); + } + + .emoji-button { + width: 45px; + height: 40px; + padding-top: 4px; + border: none; + outline: none; + background: transparent; + cursor: pointer; + max-height: 40px; + color: var(--black); + } + + .message-size-container { + display: flex; + justify-content: flex-end; + width: 100%; + } + + .message-size { + font-family: Roboto, sans-serif; + font-size: 12px; + color: black; + } + + .paperclip-icon { + color: var(--paperclip-icon); + width: 25px; + } + + .paperclip-icon:hover { + cursor: pointer; + } + + .send-icon { + width: 30px; + margin-left: 5px; + transition: all 0.1s ease-in-out; + cursor: pointer; + } + + .send-icon:hover { + filter: brightness(1.1); + } + + .file-picker-container { + position: relative; + height: 25px; + width: 25px; + } + + .file-picker-input-container { + position: absolute; + top: 0px; + bottom: 0px; + left: 0px; + right: 0px; + z-index: 10; + opacity: 0; + overflow: hidden; + } + + input[type=file]::-webkit-file-upload-button { + cursor: pointer; + } + + .chatbar-container textarea { + display: none; + } + + .chatbar-container .chat-editor { + display: flex; + max-height: -webkit-fill-available; + width: 100%; + border-color: transparent; + margin: 0; + padding: 0; + border: none; + } + + .checkmark-icon { + width: 30px; + color: var(--mdc-theme-primary); + margin-bottom: 6px; + } + + .checkmark-icon:hover { + cursor: pointer; + } + ` + } + + constructor() { + super() + this.isLoadingMessages = true + this.isLoading = false + this.getMessageSize = this.getMessageSize.bind(this) + this.calculateIFrameHeight = this.calculateIFrameHeight.bind(this) + this.resetIFrameHeight = this.resetIFrameHeight.bind(this) + this.addGlobalEventListener = this.addGlobalEventListener.bind(this) + this.sendMessageFunc = this.sendMessageFunc.bind(this) + this.removeGlobalEventListener = this.removeGlobalEventListener.bind(this) + this.initialChat = this.initialChat.bind(this) + this.iframeHeight = 42 + this.chatMessageSize = 0 + this.userName = window.parent.reduxStore.getState().app.accountInfo.names[0] + this.theme = localStorage.getItem('qortalTheme') ? localStorage.getItem('qortalTheme') : 'light' + } + + render() { + let scrollHeightBool = false; + try { + if (this.chatMessageInput && this.chatMessageInput.contentDocument.body.scrollHeight > 60 && this.shadowRoot.querySelector(".chat-editor").contentDocument.body.querySelector("#chatbarId").innerHTML.trim() !== "") { + scrollHeightBool = true; + } + } catch (error) { + scrollHeightBool = false; + } + return html` +
+
{ + this.preventUserSendingImage(e) + }}> + + +
+ +
+
+ + + + ${this.editedMessageObj ? ( + html` +
+ ${this.isLoading === false ? html` + { + this.sendMessageFunc(); + }} + > + + ` : + html` + + `} +
+ ` + ) : + html` +
+ ${this.isLoading === false ? html` + send-icon { + this.sendMessageFunc(); + }} + /> + ` : + html` + + `} +
+ ` + } +
+ ${this.chatMessageSize >= 750 ? + html` +
+
+ ${`Your message size is of ${this.chatMessageSize} bytes out of a maximum of 1000`} +
+
+ ` : + html``} + + ` + } + + preventUserSendingImage(e) { + if (!this.userName) { + e.preventDefault(); + parentEpml.request('showSnackBar', get("chatpage.cchange27")); + }; + } + + initialChat(e) { + if (!this.chatEditor?.contentDiv.matches(':focus')) { + // WARNING: Deprecated methods from KeyBoard Event + if (e.code === "Space" || e.keyCode === 32 || e.which === 32) { + this.chatEditor.insertText(' '); + } else if (inputKeyCodes.includes(e.keyCode)) { + this.chatEditor.insertText(e.key); + return this.chatEditor.focus(); + } else { + return this.chatEditor.focus(); + } + } + } + + addGlobalEventListener(){ + document.addEventListener('keydown', this.initialChat); + } + + removeGlobalEventListener(){ + document.removeEventListener('keydown', this.initialChat); + } + + async firstUpdated() { + if (this.hasGlobalEvents) { + this.addGlobalEventListener(); + } + + window.addEventListener('storage', () => { + const checkTheme = localStorage.getItem('qortalTheme'); + const chatbar = this.shadowRoot.getElementById(this.iframeId).contentWindow.document.getElementById('chatbarId'); + if (checkTheme === 'dark') { + this.theme = 'dark'; + chatbar.style.cssText = "color:#ffffff;" + } else { + this.theme = 'light'; + chatbar.style.cssText = "color:#080808;" + } + }) + + this.emojiPickerHandler = this.shadowRoot.querySelector('.emoji-button'); + this.mirrorChatInput = this.shadowRoot.getElementById('messageBox'); + this.chatMessageInput = this.shadowRoot.getElementById(this.iframeId); + + this.emojiPicker = new EmojiPicker({ + style: "twemoji", + twemojiBaseUrl: '/emoji/', + showPreview: false, + showVariants: false, + showAnimation: false, + position: 'top-start', + boxShadow: 'rgba(4, 4, 5, 0.15) 0px 0px 0px 1px, rgba(0, 0, 0, 0.24) 0px 8px 16px 0px', + zIndex: 100 + + }); + + this.emojiPicker.on('emoji', selection => { + const emojiHtmlString = `${selection.emoji}`; + this.chatEditor.insertEmoji(emojiHtmlString); + }); + + + this.emojiPickerHandler.addEventListener('click', () => this.emojiPicker.togglePicker(this.emojiPickerHandler)); + + await this.updateComplete; + this.initChatEditor(); + } + + async updated(changedProperties) { + if (changedProperties && changedProperties.has('editedMessageObj')) { + if (this.editedMessageObj) { + this.chatEditor.insertText(this.editedMessageObj.message); + this.getMessageSize(this.editedMessageObj.message); + } else { + this.chatEditor.insertText(""); + this.chatMessageSize = 0; + } + } + if (changedProperties && changedProperties.has('placeholder')) { + const captionEditor = this.shadowRoot.getElementById(this.iframeId).contentWindow.document.getElementById('chatbarId'); + captionEditor.setAttribute('data-placeholder', this.placeholder); + } + + if (changedProperties && changedProperties.has("imageFile")) { + this.chatMessageInput = "newChat"; + } + } + + shouldUpdate(changedProperties) { + // Only update element if prop1 changed. + if(changedProperties.has('setChatEditor') && changedProperties.size === 1) return false + return true + } + + sendMessageFunc(props) { + if (this.chatMessageSize > 1000 ) { + parentEpml.request('showSnackBar', get("chatpage.cchange29")); + return; + }; + this.chatMessageSize = 0; + this.chatEditor.updateMirror(); + this._sendMessage(props); + } + + getMessageSize(message){ + try { + const messageText = message; + // Format and Sanitize Message + const sanitizedMessage = messageText.replace(/ /gi, ' ').replace(//gi, '\n'); + const trimmedMessage = sanitizedMessage.trim(); + let messageObject = {}; + + if (this.repliedToMessageObj) { + let chatReference = this.repliedToMessageObj.reference; + if (this.repliedToMessageObj.chatReference) { + chatReference = this.repliedToMessageObj.chatReference; + } + messageObject = { + messageText: trimmedMessage, + images: [''], + repliedTo: chatReference, + version: 1 + } + } else if (this.editedMessageObj) { + let message = ""; + try { + const parsedMessageObj = JSON.parse(this.editedMessageObj.decodedMessage); + message = parsedMessageObj; + } catch (error) { + message = this.messageObj.decodedMessage + } + messageObject = { + ...message, + messageText: trimmedMessage, + } + } else if(this.imageFile && this.iframeId === 'newChat') { + messageObject = { + messageText: trimmedMessage, + images: [{ + service: "QCHAT_IMAGE", + name: '123456789123456789123456789', + identifier: '123456' + }], + repliedTo: '', + version: 1 + }; + } else { + messageObject = { + messageText: trimmedMessage, + images: [''], + repliedTo: '', + version: 1 + }; + } + + const stringified = JSON.stringify(messageObject); + const size = new Blob([stringified]).size; + this.chatMessageSize = size; + } catch (error) { + console.error(error) + } + + } + + calculateIFrameHeight(height) { + setTimeout(()=> { + const editorTest = this.shadowRoot.getElementById(this.iframeId).contentWindow.document.getElementById('chatbarId').scrollHeight; + this.iframeHeight = editorTest + 20; + }, 50) + } + resetIFrameHeight(height) { + this.iframeHeight = 42; + } + initChatEditor() { + const ChatEditor = function (editorConfig) { + const ChatEditor = function () { + const editor = this; + editor.init(); + }; + + ChatEditor.prototype.getValue = function () { + const editor = this; + + if (editor.contentDiv) { + return editor.contentDiv.innerHTML; + } + }; + + ChatEditor.prototype.setValue = function (value) { + const editor = this; + + if (value) { + editor.contentDiv.innerHTML = value; + editor.updateMirror(); + } + + editor.focus(); + }; + + ChatEditor.prototype.resetValue = function () { + const editor = this; + editor.contentDiv.innerHTML = ''; + editor.updateMirror(); + editor.focus(); + editorConfig.resetIFrameHeight() + }; + + ChatEditor.prototype.styles = function () { + const editor = this; + + editor.styles = document.createElement('style'); + editor.styles.setAttribute('type', 'text/css'); + editor.styles.innerText = ` + html { + cursor: text; + } + + .chatbar-body { + display: flex; + align-items: center; + } + + .chatbar-body::-webkit-scrollbar-track { + background-color: whitesmoke; + border-radius: 7px; + } + + .chatbar-body::-webkit-scrollbar { + width: 6px; + border-radius: 7px; + background-color: whitesmoke; + } + + .chatbar-body::-webkit-scrollbar-thumb { + background-color: rgb(180, 176, 176); + border-radius: 7px; + transition: all 0.3s ease-in-out; + } + + .chatbar-body::-webkit-scrollbar-thumb:hover { + background-color: rgb(148, 146, 146); + cursor: pointer; + } + + div { + font-size: 1rem; + line-height: 1.38rem; + font-weight: 400; + font-family: "Open Sans", helvetica, sans-serif; + padding-right: 3px; + text-align: left; + white-space: break-spaces; + word-break: break-word; + outline: none; + min-height: 20px; + width: 100%; + } + + div[contentEditable=true]:empty:before { + content: attr(data-placeholder); + display: block; + text-overflow: ellipsis; + overflow: hidden; + user-select: none; + white-space: nowrap; + opacity: 0.7; + } + + div[contentEditable=false]{ + background: rgba(0,0,0,0.1); + width: 100%; + } + + img.emoji { + width: 1.7em; + height: 1.5em; + margin-bottom: -2px; + vertical-align: bottom; + } + `; + editor.content.head.appendChild(editor.styles); + }; + + ChatEditor.prototype.enable = function () { + const editor = this; + + editor.contentDiv.setAttribute('contenteditable', 'true'); + editor.focus(); + }; + + ChatEditor.prototype.getMirrorElement = function (){ + return editor.mirror + } + + ChatEditor.prototype.disable = function () { + const editor = this; + + editor.contentDiv.setAttribute('contenteditable', 'false'); + }; + + ChatEditor.prototype.state = function () { + const editor = this; + + return editor.contentDiv.getAttribute('contenteditable'); + }; + + ChatEditor.prototype.focus = function () { + const editor = this; + + editor.contentDiv.focus(); + }; + + ChatEditor.prototype.clearSelection = function () { + const editor = this; + + let selection = editor.content.getSelection().toString(); + if (!/^\s*$/.test(selection)) editor.content.getSelection().removeAllRanges(); + }; + + ChatEditor.prototype.insertEmoji = function (emojiImg) { + const editor = this; + + const doInsert = () => { + + if (editor.content.queryCommandSupported("InsertHTML")) { + editor.content.execCommand("insertHTML", false, emojiImg); + editor.updateMirror(); + } + }; + + editor.focus(); + return doInsert(); + }; + + ChatEditor.prototype.insertText = function (text) { + const editor = this; + + const parsedText = editorConfig.emojiPicker.parse(text); + const doPaste = () => { + + if (editor.content.queryCommandSupported("InsertHTML")) { + editor.content.execCommand("insertHTML", false, parsedText); + editor.updateMirror(); + } + }; + + editor.focus(); + return doPaste(); + }; + + ChatEditor.prototype.updateMirror = function () { + const editor = this; + + const chatInputValue = editor.getValue(); + const filteredValue = chatInputValue.replace(//g, ''); + + let unescapedValue = editorConfig.unescape(filteredValue); + editor.mirror.value = unescapedValue; + }; + + ChatEditor.prototype.listenChanges = function () { + + const editor = this; + + const events = ['drop', 'contextmenu', 'mouseup', 'click', 'touchend', 'keydown', 'blur', 'paste'] + + for (let i = 0; i < events.length; i++) { + const event = events[i] + editor.content.body.addEventListener(event, async function (e) { + + if (e.type === 'click') { + e.preventDefault(); + e.stopPropagation(); + } + + if (e.type === 'paste') { + e.preventDefault(); + const item_list = await navigator.clipboard.read(); + let image_type; // we will feed this later + const item = item_list.find( item => // choose the one item holding our image + item.types.some( type => { + if (type.startsWith( 'image/')) { + image_type = type; + return true; + } + }) + ); + if(item){ + const blob = item && await item.getType( image_type ); + var file = new File([blob], "name", { + type: image_type + }); + + editorConfig.insertImage(file) + } else { + navigator.clipboard.readText() + .then(clipboardText => { + let escapedText = editorConfig.escape(clipboardText); + editor.insertText(escapedText); + }) + .then(() => { + editorConfig.getMessageSize(editorConfig.editableElement.contentDocument.body.querySelector("#chatbarId").innerHTML); + }) + .catch(err => { + // Fallback if everything fails... + let textData = (e.originalEvent || e).clipboardData.getData('text/plain'); + editor.insertText(textData); + }) + } + + + return false; + } + + if (e.type === 'contextmenu') { + e.preventDefault(); + e.stopPropagation(); + return false; + } + + if (e.type === 'keydown') { + await new Promise((res, rej) => { + setTimeout(() => { + editorConfig.calculateIFrameHeight(editorConfig.editableElement.contentDocument.body.scrollHeight); + editorConfig.getMessageSize(editorConfig.editableElement.contentDocument.body.querySelector("#chatbarId").innerHTML); + }, 0); + res(); + }) + + // Handle Enter + if (e.keyCode === 13 && !e.shiftKey) { + + + if (editor.state() === 'false') return false; + if (editorConfig.iframeId === 'newChat') { + editorConfig.sendFunc( + { + type: 'image', + imageFile: editorConfig.imageFile, + } + ); + } else { + editorConfig.sendFunc(); + } + + e.preventDefault(); + return false; + } + + // Handle Commands with CTR or CMD + if (e.ctrlKey || e.metaKey) { + switch (e.keyCode) { + case 66: + case 98: e.preventDefault(); + return false; + case 73: + case 105: e.preventDefault(); + return false; + case 85: + case 117: e.preventDefault(); + return false; + } + + return false; + } + } + + if (e.type === 'blur') { + editor.clearSelection(); + } + + if (e.type === 'drop') { + e.preventDefault(); + + let droppedText = e.dataTransfer.getData('text/plain') + let escapedText = editorConfig.escape(droppedText) + + editor.insertText(escapedText); + return false; + } + + editor.updateMirror(); + }); + } + + editor.content.addEventListener('click', function (event) { + event.preventDefault(); + editor.focus(); + }); + }; + + ChatEditor.prototype.remove = function () { + const editor = this; + var old_element = editor.content.body; + var new_element = old_element.cloneNode(true); + editor.content.body.parentNode.replaceChild(new_element, old_element); + while (editor.content.body.firstChild) { + editor.content.body.removeChild(editor.content.body.lastChild); + } + + }; + + ChatEditor.prototype.init = function () { + const editor = this; + + editor.frame = editorConfig.editableElement; + editor.mirror = editorConfig.mirrorElement; + + editor.content = (editor.frame.contentDocument || editor.frame.document); + editor.content.body.classList.add("chatbar-body"); + + let elemDiv = document.createElement('div'); + elemDiv.setAttribute('contenteditable', 'true'); + elemDiv.setAttribute('spellcheck', 'false'); + elemDiv.setAttribute('data-placeholder', editorConfig.placeholder); + elemDiv.style.cssText = `width:100%; ${editorConfig.theme === "dark" ? "color:#ffffff;" : "color: #080808"}`; + elemDiv.id = 'chatbarId'; + editor.content.body.appendChild(elemDiv); + editor.contentDiv = editor.frame.contentDocument.body.firstChild; + editor.styles(); + editor.listenChanges(); + + }; + + + function doInit() { + return new ChatEditor(); + } + return doInit(); + }; + + const editorConfig = { + getMessageSize: this.getMessageSize, + calculateIFrameHeight: this.calculateIFrameHeight, + mirrorElement: this.mirrorChatInput, + editableElement: this.chatMessageInput, + sendFunc: this.sendMessageFunc, + emojiPicker: this.emojiPicker, + escape: escape, + unescape: unescape, + placeholder: this.placeholder, + imageFile: this.imageFile, + requestUpdate: this.requestUpdate, + insertImage: this.insertImage, + chatMessageSize: this.chatMessageSize, + addGlobalEventListener: this.addGlobalEventListener, + removeGlobalEventListener: this.removeGlobalEventListener, + iframeId: this.iframeId, + theme: this.theme, + resetIFrameHeight: this.resetIFrameHeight + }; + const newChat = new ChatEditor(editorConfig); + this.setChatEditor(newChat); + } +} + +window.customElements.define("chat-text-editor", ChatTextEditor) diff --git a/qortal-ui-plugins/plugins/core/components/ChatTextEditor.js b/qortal-ui-plugins/plugins/core/components/ChatTextEditor.js index dfc572bc..6ce77fbc 100644 --- a/qortal-ui-plugins/plugins/core/components/ChatTextEditor.js +++ b/qortal-ui-plugins/plugins/core/components/ChatTextEditor.js @@ -4,6 +4,10 @@ import { escape, unescape } from 'html-escaper'; import { EmojiPicker } from 'emoji-picker-js'; import { inputKeyCodes } from '../../utils/keyCodes.js'; import { Epml } from '../../../epml.js'; +import { Editor } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' +import Underline from '@tiptap/extension-underline'; +import Image from '@tiptap/extension-image' const parentEpml = new Epml({ type: 'WINDOW', source: window.parent }); class ChatTextEditor extends LitElement { @@ -17,12 +21,12 @@ class ChatTextEditor extends LitElement { insertImage: { attribute: false }, iframeHeight: { type: Number }, editedMessageObj: { type: Object }, - chatEditor: { type: Object }, setChatEditor: { attribute: false }, iframeId: { type: String }, hasGlobalEvents: { type: Boolean }, chatMessageSize: { type: Number }, isEditMessageOpen: { type: Boolean }, + editor: {type: Object}, theme: { type: String, reflect: true @@ -39,8 +43,8 @@ class ChatTextEditor extends LitElement { justify-content: center; align-items: center; height: auto; - overflow-y: hidden; width: 100%; + overflow-y: hidden; } .chatbar-container { width: 100%; @@ -63,6 +67,7 @@ class ChatTextEditor extends LitElement { cursor: pointer; max-height: 40px; color: var(--black); + margin-bottom: 5px; } .message-size-container { @@ -101,6 +106,7 @@ class ChatTextEditor extends LitElement { position: relative; height: 25px; width: 25px; + margin-bottom: 10px; } .file-picker-input-container { @@ -141,6 +147,129 @@ class ChatTextEditor extends LitElement { .checkmark-icon:hover { cursor: pointer; } + + .element { + width: 100%; + max-height: 100%; + overflow: auto; + color: var(--black); + padding: 0px 10px; + } + .element::-webkit-scrollbar-track { + background-color: whitesmoke; + border-radius: 7px; + } + + .element::-webkit-scrollbar { + width: 6px; + border-radius: 7px; + background-color: whitesmoke; + } + + .element::-webkit-scrollbar-thumb { + background-color: rgb(180, 176, 176); + border-radius: 7px; + transition: all 0.3s ease-in-out; + } + + .element::-webkit-scrollbar-thumb:hover { + background-color: rgb(148, 146, 146); + cursor: pointer; + } + .ProseMirror:focus { + outline: none; + } + + .is-active { + background-color: var(--white) + } + + .ProseMirror > * + * { + margin-top: 0.75em; + outline: none; + } + + .ProseMirror ul, + ol { + padding: 0 1rem; + } + + .ProseMirror h1, + h2, + h3, + h4, + h5, + h6 { + line-height: 1.1; + } + + .ProseMirror code { + background-color: rgba(#616161, 0.1); + color: #616161; + } + + .ProseMirror pre { + background: #0D0D0D; + color: #FFF; + font-family: 'JetBrainsMono', monospace; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + } + .ProseMirror pre code { + color: inherit; + padding: 0; + background: none; + font-size: 0.8rem; + } + + + .ProseMirror img { + width: 1.7em; + height: 1.5em; + margin: 0px; + + } + + .ProseMirror blockquote { + padding-left: 1rem; + border-left: 2px solid rgba(#0D0D0D, 0.1); + } + + .ProseMirror hr { + border: none; + border-top: 2px solid rgba(#0D0D0D, 0.1); + margin: 2rem 0; + } + .chatbar-button-single { + background: var(--white); + outline: none; + border: none; + color: var(--black); + padding: 4px 8px; + border-radius: 5px; + cursor: pointer; + margin-right: 2px; + filter: brightness(100%); + transition: all 0.2s; + } + .chatbar-button-single:hover { + filter: brightness(120%); + } + .chatbar-buttons { + visibility: hidden; + transition: all .2s; + } + .chatbar-container:hover .chatbar-buttons { + visibility: visible; + } + .ProseMirror p.is-editor-empty:first-child::before { + color: #adb5bd; + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} + ` } @@ -159,21 +288,68 @@ class ChatTextEditor extends LitElement { this.chatMessageSize = 0 this.userName = window.parent.reduxStore.getState().app.accountInfo.names[0] this.theme = localStorage.getItem('qortalTheme') ? localStorage.getItem('qortalTheme') : 'light' + this.editor = null } render() { + console.log('this.editor', this.editor) let scrollHeightBool = false; try { - if (this.chatMessageInput && this.chatMessageInput.contentDocument.body.scrollHeight > 60 && this.shadowRoot.querySelector(".chat-editor").contentDocument.body.querySelector("#chatbarId").innerHTML.trim() !== "") { + console.log('this.chatMessageInput', this.chatMessageInput) + if (this.chatMessageInput && this.chatMessageInput.scrollHeight > 60) { scrollHeightBool = true; } } catch (error) { scrollHeightBool = false; } return html` +
+ style="align-items: flex-end; position: relative"> +
+ + + + +
- + +
${this.editedMessageObj ? ( html` -
+
${this.isLoading === false ? html` @@ -271,17 +446,17 @@ class ChatTextEditor extends LitElement { } initialChat(e) { - if (!this.chatEditor?.contentDiv.matches(':focus')) { - // WARNING: Deprecated methods from KeyBoard Event - if (e.code === "Space" || e.keyCode === 32 || e.which === 32) { - this.chatEditor.insertText(' '); - } else if (inputKeyCodes.includes(e.keyCode)) { - this.chatEditor.insertText(e.key); - return this.chatEditor.focus(); - } else { - return this.chatEditor.focus(); - } - } + // if (!this.chatEditor?.contentDiv.matches(':focus')) { + // // WARNING: Deprecated methods from KeyBoard Event + // if (e.code === "Space" || e.keyCode === 32 || e.which === 32) { + // this.chatEditor.insertText(' '); + // } else if (inputKeyCodes.includes(e.keyCode)) { + // this.chatEditor.insertText(e.key); + // return this.chatEditor.focus(); + // } else { + // return this.chatEditor.focus(); + // } + // } } addGlobalEventListener(){ @@ -294,24 +469,38 @@ class ChatTextEditor extends LitElement { async firstUpdated() { if (this.hasGlobalEvents) { - this.addGlobalEventListener(); + // this.addGlobalEventListener(); } - + Image.configure({ + inline: true, + }) + // this.editor = new Editor({ + // element: this.shadowRoot.querySelector('.element'), + // extensions: [ + // StarterKit, + // Underline, + // Image + // ], + // content: '

Hello World!

', + // }) + window.addEventListener('storage', () => { const checkTheme = localStorage.getItem('qortalTheme'); - const chatbar = this.shadowRoot.getElementById(this.iframeId).contentWindow.document.getElementById('chatbarId'); + const chatbar = this.shadowRoot.querySelector('.element') if (checkTheme === 'dark') { this.theme = 'dark'; chatbar.style.cssText = "color:#ffffff;" + } else { this.theme = 'light'; chatbar.style.cssText = "color:#080808;" + } }) this.emojiPickerHandler = this.shadowRoot.querySelector('.emoji-button'); this.mirrorChatInput = this.shadowRoot.getElementById('messageBox'); - this.chatMessageInput = this.shadowRoot.getElementById(this.iframeId); + this.chatMessageInput = this.shadowRoot.querySelector('.element') this.emojiPicker = new EmojiPicker({ style: "twemoji", @@ -327,29 +516,32 @@ class ChatTextEditor extends LitElement { this.emojiPicker.on('emoji', selection => { const emojiHtmlString = `${selection.emoji}`; - this.chatEditor.insertEmoji(emojiHtmlString); + console.log('hello insert 6', selection) + this.editor.commands.insertContent(selection.emoji, { + parseOptions: { + preserveWhitespace: false + } + }) }); this.emojiPickerHandler.addEventListener('click', () => this.emojiPicker.togglePicker(this.emojiPickerHandler)); await this.updateComplete; - this.initChatEditor(); + // this.initChatEditor(); } async updated(changedProperties) { if (changedProperties && changedProperties.has('editedMessageObj')) { if (this.editedMessageObj) { - this.chatEditor.insertText(this.editedMessageObj.message); + this.editor.commands.setContent(this.editedMessageObj.message) this.getMessageSize(this.editedMessageObj.message); } else { - this.chatEditor.insertText(""); this.chatMessageSize = 0; } } if (changedProperties && changedProperties.has('placeholder')) { - const captionEditor = this.shadowRoot.getElementById(this.iframeId).contentWindow.document.getElementById('chatbarId'); - captionEditor.setAttribute('data-placeholder', this.placeholder); + this.updatePlaceholder(this.editor, this.placeholder ) } if (changedProperties && changedProperties.has("imageFile")) { @@ -364,13 +556,12 @@ class ChatTextEditor extends LitElement { } sendMessageFunc(props) { - if (this.chatMessageSize > 1000 ) { - parentEpml.request('showSnackBar', get("chatpage.cchange29")); - return; - }; - this.chatMessageSize = 0; - this.chatEditor.updateMirror(); - this._sendMessage(props); + // if (this.chatMessageSize > 1000 ) { + // parentEpml.request('showSnackBar', get("chatpage.cchange29")); + // return; + // }; + // this.chatMessageSize = 0; + this._sendMessage(props, this.editor.getJSON()); } getMessageSize(message){ @@ -442,387 +633,7 @@ class ChatTextEditor extends LitElement { resetIFrameHeight(height) { this.iframeHeight = 42; } - initChatEditor() { - const ChatEditor = function (editorConfig) { - const ChatEditor = function () { - const editor = this; - editor.init(); - }; - - ChatEditor.prototype.getValue = function () { - const editor = this; - - if (editor.contentDiv) { - return editor.contentDiv.innerHTML; - } - }; - - ChatEditor.prototype.setValue = function (value) { - const editor = this; - - if (value) { - editor.contentDiv.innerHTML = value; - editor.updateMirror(); - } - - editor.focus(); - }; - - ChatEditor.prototype.resetValue = function () { - const editor = this; - editor.contentDiv.innerHTML = ''; - editor.updateMirror(); - editor.focus(); - editorConfig.resetIFrameHeight() - }; - - ChatEditor.prototype.styles = function () { - const editor = this; - - editor.styles = document.createElement('style'); - editor.styles.setAttribute('type', 'text/css'); - editor.styles.innerText = ` - html { - cursor: text; - } - - .chatbar-body { - display: flex; - align-items: center; - } - - .chatbar-body::-webkit-scrollbar-track { - background-color: whitesmoke; - border-radius: 7px; - } - - .chatbar-body::-webkit-scrollbar { - width: 6px; - border-radius: 7px; - background-color: whitesmoke; - } - - .chatbar-body::-webkit-scrollbar-thumb { - background-color: rgb(180, 176, 176); - border-radius: 7px; - transition: all 0.3s ease-in-out; - } - - .chatbar-body::-webkit-scrollbar-thumb:hover { - background-color: rgb(148, 146, 146); - cursor: pointer; - } - - div { - font-size: 1rem; - line-height: 1.38rem; - font-weight: 400; - font-family: "Open Sans", helvetica, sans-serif; - padding-right: 3px; - text-align: left; - white-space: break-spaces; - word-break: break-word; - outline: none; - min-height: 20px; - width: 100%; - } - - div[contentEditable=true]:empty:before { - content: attr(data-placeholder); - display: block; - text-overflow: ellipsis; - overflow: hidden; - user-select: none; - white-space: nowrap; - opacity: 0.7; - } - - div[contentEditable=false]{ - background: rgba(0,0,0,0.1); - width: 100%; - } - - img.emoji { - width: 1.7em; - height: 1.5em; - margin-bottom: -2px; - vertical-align: bottom; - } - `; - editor.content.head.appendChild(editor.styles); - }; - - ChatEditor.prototype.enable = function () { - const editor = this; - - editor.contentDiv.setAttribute('contenteditable', 'true'); - editor.focus(); - }; - - ChatEditor.prototype.getMirrorElement = function (){ - return editor.mirror - } - - ChatEditor.prototype.disable = function () { - const editor = this; - - editor.contentDiv.setAttribute('contenteditable', 'false'); - }; - - ChatEditor.prototype.state = function () { - const editor = this; - - return editor.contentDiv.getAttribute('contenteditable'); - }; - - ChatEditor.prototype.focus = function () { - const editor = this; - - editor.contentDiv.focus(); - }; - - ChatEditor.prototype.clearSelection = function () { - const editor = this; - - let selection = editor.content.getSelection().toString(); - if (!/^\s*$/.test(selection)) editor.content.getSelection().removeAllRanges(); - }; - - ChatEditor.prototype.insertEmoji = function (emojiImg) { - const editor = this; - - const doInsert = () => { - - if (editor.content.queryCommandSupported("InsertHTML")) { - editor.content.execCommand("insertHTML", false, emojiImg); - editor.updateMirror(); - } - }; - - editor.focus(); - return doInsert(); - }; - - ChatEditor.prototype.insertText = function (text) { - const editor = this; - - const parsedText = editorConfig.emojiPicker.parse(text); - const doPaste = () => { - - if (editor.content.queryCommandSupported("InsertHTML")) { - editor.content.execCommand("insertHTML", false, parsedText); - editor.updateMirror(); - } - }; - - editor.focus(); - return doPaste(); - }; - - ChatEditor.prototype.updateMirror = function () { - const editor = this; - - const chatInputValue = editor.getValue(); - const filteredValue = chatInputValue.replace(//g, ''); - - let unescapedValue = editorConfig.unescape(filteredValue); - editor.mirror.value = unescapedValue; - }; - - ChatEditor.prototype.listenChanges = function () { - - const editor = this; - - const events = ['drop', 'contextmenu', 'mouseup', 'click', 'touchend', 'keydown', 'blur', 'paste'] - - for (let i = 0; i < events.length; i++) { - const event = events[i] - editor.content.body.addEventListener(event, async function (e) { - - if (e.type === 'click') { - e.preventDefault(); - e.stopPropagation(); - } - - if (e.type === 'paste') { - e.preventDefault(); - const item_list = await navigator.clipboard.read(); - let image_type; // we will feed this later - const item = item_list.find( item => // choose the one item holding our image - item.types.some( type => { - if (type.startsWith( 'image/')) { - image_type = type; - return true; - } - }) - ); - if(item){ - const blob = item && await item.getType( image_type ); - var file = new File([blob], "name", { - type: image_type - }); - - editorConfig.insertImage(file) - } else { - navigator.clipboard.readText() - .then(clipboardText => { - let escapedText = editorConfig.escape(clipboardText); - editor.insertText(escapedText); - }) - .then(() => { - editorConfig.getMessageSize(editorConfig.editableElement.contentDocument.body.querySelector("#chatbarId").innerHTML); - }) - .catch(err => { - // Fallback if everything fails... - let textData = (e.originalEvent || e).clipboardData.getData('text/plain'); - editor.insertText(textData); - }) - } - - - return false; - } - - if (e.type === 'contextmenu') { - e.preventDefault(); - e.stopPropagation(); - return false; - } - - if (e.type === 'keydown') { - await new Promise((res, rej) => { - setTimeout(() => { - editorConfig.calculateIFrameHeight(editorConfig.editableElement.contentDocument.body.scrollHeight); - editorConfig.getMessageSize(editorConfig.editableElement.contentDocument.body.querySelector("#chatbarId").innerHTML); - }, 0); - res(); - }) - - // Handle Enter - if (e.keyCode === 13 && !e.shiftKey) { - - - if (editor.state() === 'false') return false; - if (editorConfig.iframeId === 'newChat') { - editorConfig.sendFunc( - { - type: 'image', - imageFile: editorConfig.imageFile, - } - ); - } else { - editorConfig.sendFunc(); - } - - e.preventDefault(); - return false; - } - - // Handle Commands with CTR or CMD - if (e.ctrlKey || e.metaKey) { - switch (e.keyCode) { - case 66: - case 98: e.preventDefault(); - return false; - case 73: - case 105: e.preventDefault(); - return false; - case 85: - case 117: e.preventDefault(); - return false; - } - - return false; - } - } - - if (e.type === 'blur') { - editor.clearSelection(); - } - - if (e.type === 'drop') { - e.preventDefault(); - - let droppedText = e.dataTransfer.getData('text/plain') - let escapedText = editorConfig.escape(droppedText) - - editor.insertText(escapedText); - return false; - } - - editor.updateMirror(); - }); - } - - editor.content.addEventListener('click', function (event) { - event.preventDefault(); - editor.focus(); - }); - }; - - ChatEditor.prototype.remove = function () { - const editor = this; - var old_element = editor.content.body; - var new_element = old_element.cloneNode(true); - editor.content.body.parentNode.replaceChild(new_element, old_element); - while (editor.content.body.firstChild) { - editor.content.body.removeChild(editor.content.body.lastChild); - } - - }; - - ChatEditor.prototype.init = function () { - const editor = this; - - editor.frame = editorConfig.editableElement; - editor.mirror = editorConfig.mirrorElement; - - editor.content = (editor.frame.contentDocument || editor.frame.document); - editor.content.body.classList.add("chatbar-body"); - - let elemDiv = document.createElement('div'); - elemDiv.setAttribute('contenteditable', 'true'); - elemDiv.setAttribute('spellcheck', 'false'); - elemDiv.setAttribute('data-placeholder', editorConfig.placeholder); - elemDiv.style.cssText = `width:100%; ${editorConfig.theme === "dark" ? "color:#ffffff;" : "color: #080808"}`; - elemDiv.id = 'chatbarId'; - editor.content.body.appendChild(elemDiv); - editor.contentDiv = editor.frame.contentDocument.body.firstChild; - editor.styles(); - editor.listenChanges(); - - }; - - - function doInit() { - return new ChatEditor(); - } - return doInit(); - }; - - const editorConfig = { - getMessageSize: this.getMessageSize, - calculateIFrameHeight: this.calculateIFrameHeight, - mirrorElement: this.mirrorChatInput, - editableElement: this.chatMessageInput, - sendFunc: this.sendMessageFunc, - emojiPicker: this.emojiPicker, - escape: escape, - unescape: unescape, - placeholder: this.placeholder, - imageFile: this.imageFile, - requestUpdate: this.requestUpdate, - insertImage: this.insertImage, - chatMessageSize: this.chatMessageSize, - addGlobalEventListener: this.addGlobalEventListener, - removeGlobalEventListener: this.removeGlobalEventListener, - iframeId: this.iframeId, - theme: this.theme, - resetIFrameHeight: this.resetIFrameHeight - }; - const newChat = new ChatEditor(editorConfig); - this.setChatEditor(newChat); - } + } window.customElements.define("chat-text-editor", ChatTextEditor) diff --git a/qortal-ui-plugins/plugins/core/messaging/q-chat/q-chat.src.js b/qortal-ui-plugins/plugins/core/messaging/q-chat/q-chat.src.js index 18cec93b..86e43929 100644 --- a/qortal-ui-plugins/plugins/core/messaging/q-chat/q-chat.src.js +++ b/qortal-ui-plugins/plugins/core/messaging/q-chat/q-chat.src.js @@ -22,6 +22,10 @@ import '@material/mwc-dialog' import '@material/mwc-icon' import '@material/mwc-snackbar' import '@vaadin/grid' +import StarterKit from '@tiptap/starter-kit' +import Underline from '@tiptap/extension-underline'; +import Placeholder from '@tiptap/extension-placeholder' +import { Editor, Extension } from '@tiptap/core' const parentEpml = new Epml({ type: 'WINDOW', source: window.parent }) @@ -47,7 +51,8 @@ class Chat extends LitElement { openPrivateMessage: { type: Boolean }, userFound: { type: Array}, userFoundModalOpen: { type: Boolean }, - userSelected: { type: Object } + userSelected: { type: Object }, + editor: {type: Object} } } @@ -94,6 +99,66 @@ class Chat extends LitElement { this.activeChatHeadUrl = url } + resetChatEditor(){ + + this.editor.commands.setContent('') + + } + async getUpdateCompleteTextEditor() { + await super.getUpdateComplete(); + const marginElements = Array.from(this.shadowRoot.querySelectorAll('chat-text-editor')); + await Promise.all(marginElements.map(el => el.updateComplete)); + const marginElements2 = Array.from(this.shadowRoot.querySelectorAll('wrapper-modal')); + await Promise.all(marginElements2.map(el => el.updateComplete)); + return true; + } + + async connectedCallback() { + super.connectedCallback(); + await this.getUpdateCompleteTextEditor(); + + const elementChatId = this.shadowRoot.getElementById('messageBox').shadowRoot.getElementById('privateMessage') + console.log({elementChatId}) + this.editor = new Editor({ + element: elementChatId, + extensions: [ + StarterKit, + Underline, + Placeholder.configure({ + placeholder: 'Write something …', + }), + Extension.create({ + addKeyboardShortcuts:()=> { + return { + 'Enter': ()=> { + const chatTextEditor = this.shadowRoot.getElementById('messageBox') + chatTextEditor.sendMessageFunc({ + }) + return true + } + } + }}) + ] + }) + + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.editor.destroy() + + } + + updatePlaceholder(editor, text){ + editor.extensionManager.extensions.forEach((extension) => { + if (extension.name === "placeholder") { + + extension.options["placeholder"] = text + editor.commands.focus('end') + } + }) + } + render() { return html`
@@ -127,13 +192,13 @@ class Chat extends LitElement { { - this.chatEditor.resetValue(); + this.resetChatEditor(); this.openPrivateMessage = false; this.shadowRoot.getElementById('sendTo').value = ""; this.userFoundModalOpen = false; this.userFound = []; } } - style=${this.openPrivateMessage ? "display: block" : "display: none"}> + style=${this.openPrivateMessage ? "visibility:visible;z-index:50" : "visibility: hidden;z-index:-100;position: relative"}>
@@ -177,21 +242,21 @@ class Chat extends LitElement { iframeId="privateMessage" ?hasGlobalEvents=${false} placeholder="${translate("chatpage.cchange8")}" - .setChatEditor=${(editor)=> this.setChatEditor(editor)} - .chatEditor=${this.chatEditor} .imageFile=${this.imageFile} ._sendMessage=${this._sendMessage} .insertImage=${this.insertImage} ?isLoading=${this.isLoading} .isLoadingMessages=${false} id="messageBox" + .editor=${this.editor} + .updatePlaceholder=${(editor, value)=> this.updatePlaceholder(editor, value)} >