From 197da963251302dc548b0a839d7adfce5e3c4a39 Mon Sep 17 00:00:00 2001 From: Eduard Kuzmenko Date: Sun, 16 May 2021 06:07:17 +0400 Subject: [PATCH] List navigation for stickers helper --- src/components/chat/autocompleteHelper.ts | 32 +++++ src/components/chat/input.ts | 46 +++--- src/components/chat/markupTooltip.ts | 2 +- src/components/chat/stickersHelper.ts | 47 +++--- src/components/emoticonsDropdown/index.ts | 10 +- src/components/inputField.ts | 4 +- src/components/popups/createPoll.ts | 10 +- src/helpers/dom/attachListNavigation.ts | 160 +++++++++++++++++++++ src/helpers/dom/getRichElementValue.ts | 123 ++++++++++++++++ src/helpers/dom/getRichValue.ts | 121 +--------------- src/helpers/dom/getRichValueWithCaret.ts | 52 +++++++ src/helpers/dom/isInputEmpty.ts | 2 +- src/lib/appManagers/appPollsManager.ts | 2 + src/scss/partials/_autocompleteHelper.scss | 28 ++++ src/scss/partials/_chatStickersHelper.scss | 37 +---- src/scss/style.scss | 8 ++ 16 files changed, 476 insertions(+), 208 deletions(-) create mode 100644 src/components/chat/autocompleteHelper.ts create mode 100644 src/helpers/dom/attachListNavigation.ts create mode 100644 src/helpers/dom/getRichElementValue.ts create mode 100644 src/helpers/dom/getRichValueWithCaret.ts create mode 100644 src/scss/partials/_autocompleteHelper.scss diff --git a/src/components/chat/autocompleteHelper.ts b/src/components/chat/autocompleteHelper.ts new file mode 100644 index 00000000..bd50305f --- /dev/null +++ b/src/components/chat/autocompleteHelper.ts @@ -0,0 +1,32 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +import EventListenerBase from "../../helpers/eventListenerBase"; +import rootScope from "../../lib/rootScope"; +import SetTransition from "../singleTransition"; + +export default class AutocompleteHelper extends EventListenerBase<{ + hidden: () => void, + visible: () => void, +}> { + protected container: HTMLElement; + + constructor(appendTo: HTMLElement) { + super(false); + + this.container = document.createElement('div'); + this.container.classList.add('autocomplete-helper', 'z-depth-1'); + + appendTo.append(this.container); + } + + public toggle(hide?: boolean) { + hide = hide === undefined ? this.container.classList.contains('is-visible') : hide; + SetTransition(this.container, 'is-visible', !hide, rootScope.settings.animationsEnabled ? 200 : 0, () => { + this.dispatchEvent(hide ? 'hidden' : 'visible'); + }); + } +} diff --git a/src/components/chat/input.ts b/src/components/chat/input.ts index 0d693ff6..40225748 100644 --- a/src/components/chat/input.ts +++ b/src/components/chat/input.ts @@ -51,10 +51,13 @@ import blurActiveElement from '../../helpers/dom/blurActiveElement'; import { cancelEvent } from '../../helpers/dom/cancelEvent'; import cancelSelection from '../../helpers/dom/cancelSelection'; import { attachClickEvent } from '../../helpers/dom/clickEvent'; -import getRichValue, { MarkdownType, markdownTags } from '../../helpers/dom/getRichValue'; +import getRichValue from '../../helpers/dom/getRichValue'; import isInputEmpty from '../../helpers/dom/isInputEmpty'; import isSendShortcutPressed from '../../helpers/dom/isSendShortcutPressed'; import placeCaretAtEnd from '../../helpers/dom/placeCaretAtEnd'; +import { MarkdownType, markdownTags } from '../../helpers/dom/getRichElementValue'; +import getRichValueWithCaret from '../../helpers/dom/getRichValueWithCaret'; +import searchIndexManager from '../../lib/searchIndexManager'; const RECORD_MIN_TIME = 500; const POSTING_MEDIA_NOT_ALLOWED = 'Posting media content isn\'t allowed in this group.'; @@ -62,6 +65,7 @@ const POSTING_MEDIA_NOT_ALLOWED = 'Posting media content isn\'t allowed in this type ChatInputHelperType = 'edit' | 'webpage' | 'forward' | 'reply'; export default class ChatInput { + public static AUTO_COMPLETE_REG_EXP = /(\s|^)(:|@|\/)([\S]*)$/; public pageEl = document.getElementById('page-chats') as HTMLDivElement; public messageInput: HTMLElement; public messageInputField: InputField; @@ -137,6 +141,8 @@ export default class ChatInput { public fakeRowsWrapper: HTMLDivElement; private fakePinnedControlBtn: HTMLElement; + public previousQuery: string; + constructor(private chat: Chat, private appMessagesManager: AppMessagesManager, private appDocsManager: AppDocsManager, private appChatsManager: AppChatsManager, private appPeersManager: AppPeersManager, private appWebPagesManager: AppWebPagesManager, private appImManager: AppImManager, private appDraftsManager: AppDraftsManager, private serverTimeManager: ServerTimeManager, private appNotificationsManager: AppNotificationsManager) { this.listenerSetter = new ListenerSetter(); } @@ -595,15 +601,14 @@ export default class ChatInput { public saveDraft() { if(!this.chat.peerId || this.editMsgId || this.chat.type === 'scheduled') return; - const entities: MessageEntity[] = []; - const str = getRichValue(this.messageInputField.input, entities); + const {value, entities} = getRichValue(this.messageInputField.input); let draft: DraftMessage.draftMessage; - if(str.length || this.replyToMsgId) { + if(value.length || this.replyToMsgId) { draft = { _: 'draftMessage', date: tsNow(true) + this.serverTimeManager.serverTimeOffset, - message: str, + message: value, entities: entities.length ? entities : undefined, pFlags: { no_webpage: this.noWebPage @@ -1021,35 +1026,27 @@ export default class ChatInput { //console.log('messageInput input', this.messageInput.innerText); //const value = this.messageInput.innerText; - const markdownEntities: MessageEntity[] = []; - const richValue = getRichValue(this.messageInputField.input, markdownEntities); + const {value: richValue, entities: markdownEntities, caretPos} = getRichValueWithCaret(this.messageInputField.input); //const entities = RichTextProcessor.parseEntities(value); const value = RichTextProcessor.parseMarkdown(richValue, markdownEntities); const entities = RichTextProcessor.mergeEntities(markdownEntities, RichTextProcessor.parseEntities(value)); - //this.chat.log('messageInput entities', richValue, value, markdownEntities); + this.chat.log('messageInput entities', richValue, value, markdownEntities, caretPos); if(this.stickersHelper && rootScope.settings.stickers.suggest && (this.chat.peerId > 0 || this.appChatsManager.hasRights(this.chat.peerId, 'send_stickers'))) { let emoticon = ''; - if(entities.length && entities[0]._ === 'messageEntityEmoji') { - const entity = entities[0]; - if(entity.length === richValue.length && !entity.offset) { - emoticon = richValue; - } + const entity = entities[0]; + if(entity?._ === 'messageEntityEmoji' && entity.length === richValue.length && !entity.offset) { + emoticon = richValue; } this.stickersHelper.checkEmoticon(emoticon); } - if(!richValue.trim()) { - this.appImManager.markupTooltip.hide(); - } - - const html = this.messageInput.innerHTML; - if(this.canRedoFromHTML && html !== this.canRedoFromHTML && !this.lockRedo) { + if(this.canRedoFromHTML && !this.lockRedo && this.messageInput.innerHTML !== this.canRedoFromHTML) { this.canRedoFromHTML = ''; this.undoHistory.length = 0; } @@ -1104,10 +1101,12 @@ export default class ChatInput { } } - if(this.isInputEmpty()) { + if(!richValue.trim()) { if(this.lastTimeType) { this.appMessagesManager.setTyping(this.chat.peerId, {_: 'sendMessageCancelAction'}); } + + this.appImManager.markupTooltip.hide(); } else { const time = Date.now(); if(time - this.lastTimeType >= 6000) { @@ -1346,17 +1345,16 @@ export default class ChatInput { return; } - const entities: MessageEntity[] = []; - const str = getRichValue(this.messageInputField.input, entities); + const {value, entities} = getRichValue(this.messageInputField.input); //return; if(this.editMsgId) { - this.appMessagesManager.editMessage(this.chat.getMessage(this.editMsgId), str, { + this.appMessagesManager.editMessage(this.chat.getMessage(this.editMsgId), value, { entities, noWebPage: this.noWebPage }); } else { - this.appMessagesManager.sendText(this.chat.peerId, str, { + this.appMessagesManager.sendText(this.chat.peerId, value, { entities, replyToMsgId: this.replyToMsgId, threadId: this.chat.threadId, diff --git a/src/components/chat/markupTooltip.ts b/src/components/chat/markupTooltip.ts index adb3ebfc..2d460f2e 100644 --- a/src/components/chat/markupTooltip.ts +++ b/src/components/chat/markupTooltip.ts @@ -14,9 +14,9 @@ import appNavigationController from "../appNavigationController"; import { _i18n } from "../../lib/langPack"; import { cancelEvent } from "../../helpers/dom/cancelEvent"; import { attachClickEvent } from "../../helpers/dom/clickEvent"; -import { MarkdownType, markdownTags } from "../../helpers/dom/getRichValue"; import getSelectedNodes from "../../helpers/dom/getSelectedNodes"; import isSelectionEmpty from "../../helpers/dom/isSelectionEmpty"; +import { MarkdownType, markdownTags } from "../../helpers/dom/getRichElementValue"; //import { logger } from "../../lib/logger"; export default class MarkupTooltip { diff --git a/src/components/chat/stickersHelper.ts b/src/components/chat/stickersHelper.ts index 26c0b5d9..af56f701 100644 --- a/src/components/chat/stickersHelper.ts +++ b/src/components/chat/stickersHelper.ts @@ -4,7 +4,7 @@ * https://github.com/morethanwords/tweb/blob/master/LICENSE */ -import findUpClassName from "../../helpers/dom/findUpClassName"; +import attachListNavigation from "../../helpers/dom/attachlistNavigation"; import { MyDocument } from "../../lib/appManagers/appDocsManager"; import { CHAT_ANIMATION_GROUP } from "../../lib/appManagers/appImManager"; import appStickersManager from "../../lib/appManagers/appStickersManager"; @@ -12,21 +12,36 @@ import { EmoticonsDropdown } from "../emoticonsDropdown"; import { SuperStickerRenderer } from "../emoticonsDropdown/tabs/stickers"; import LazyLoadQueue from "../lazyLoadQueue"; import Scrollable from "../scrollable"; -import SetTransition from "../singleTransition"; +import AutocompleteHelper from "./autocompleteHelper"; -export default class StickersHelper { - private container: HTMLElement; +export default class StickersHelper extends AutocompleteHelper { private stickersContainer: HTMLElement; private scrollable: Scrollable; private superStickerRenderer: SuperStickerRenderer; private lazyLoadQueue: LazyLoadQueue; private lastEmoticon = ''; - constructor(private appendTo: HTMLElement) { - this.container = document.createElement('div'); - this.container.classList.add('stickers-helper', 'z-depth-1'); + constructor(appendTo: HTMLElement) { + super(appendTo); - this.appendTo.append(this.container); + this.container.classList.add('stickers-helper'); + + this.addEventListener('visible', () => { + const list = this.stickersContainer; + const {detach} = attachListNavigation({ + list, + type: 'xy', + onSelect: (target) => { + EmoticonsDropdown.onMediaClick({target}, true); + }, + once: true + }); + + this.addEventListener('hidden', () => { + list.innerHTML = ''; + detach(); + }, true); + }); } public checkEmoticon(emoticon: string) { @@ -34,11 +49,7 @@ export default class StickersHelper { if(this.lastEmoticon && !emoticon) { if(this.container) { - SetTransition(this.container, 'is-visible', false, 200, () => { - if(this.stickersContainer) { - this.stickersContainer.innerHTML = ''; - } - }); + this.toggle(true); } } @@ -84,21 +95,13 @@ export default class StickersHelper { this.stickersContainer.replaceWith(container); this.stickersContainer = container; - SetTransition(this.container, 'is-visible', !!stickers.length, 200); + this.toggle(!stickers.length); this.scrollable.scrollTop = 0; }); }); } private init() { - this.container.addEventListener('click', (e) => { - if(!findUpClassName(e.target, 'super-sticker')) { - return; - } - - EmoticonsDropdown.onMediaClick(e, true); - }); - this.stickersContainer = document.createElement('div'); this.stickersContainer.classList.add('stickers-helper-stickers', 'super-stickers'); diff --git a/src/components/emoticonsDropdown/index.ts b/src/components/emoticonsDropdown/index.ts index b1dcc2fe..db2af217 100644 --- a/src/components/emoticonsDropdown/index.ts +++ b/src/components/emoticonsDropdown/index.ts @@ -394,7 +394,7 @@ export class EmoticonsDropdown { return stickyIntersector; }; - public static onMediaClick = (e: MouseEvent, clearDraft = false) => { + public static onMediaClick = (e: {target: EventTarget | Element}, clearDraft = false) => { let target = e.target as HTMLElement; target = findUpTag(target, 'DIV'); @@ -406,9 +406,11 @@ export class EmoticonsDropdown { if(appImManager.chat.input.sendMessageWithDocument(fileId, undefined, clearDraft)) { /* dropdown.classList.remove('active'); toggleEl.classList.remove('active'); */ - emoticonsDropdown.forceClose = true; - emoticonsDropdown.container.classList.add('disable-hover'); - emoticonsDropdown.toggle(false); + if(emoticonsDropdown.container) { + emoticonsDropdown.forceClose = true; + emoticonsDropdown.container.classList.add('disable-hover'); + emoticonsDropdown.toggle(false); + } } else { console.warn('got no doc by id:', fileId); } diff --git a/src/components/inputField.ts b/src/components/inputField.ts index 7aecf36c..5e44102a 100644 --- a/src/components/inputField.ts +++ b/src/components/inputField.ts @@ -182,7 +182,7 @@ class InputField { processInput = () => { const wasError = input.classList.contains('error'); // * https://stackoverflow.com/a/54369605 #2 to count emoji as 1 symbol - const inputLength = plainText ? (input as HTMLInputElement).value.length : [...getRichValue(input)].length; + const inputLength = plainText ? (input as HTMLInputElement).value.length : [...getRichValue(input, false).value].length; const diff = maxLength - inputLength; const isError = diff < 0; input.classList.toggle('error', isError); @@ -232,7 +232,7 @@ class InputField { } get value() { - return this.options.plainText ? (this.input as HTMLInputElement).value : getRichValue(this.input); + return this.options.plainText ? (this.input as HTMLInputElement).value : getRichValue(this.input, false).value; //return getRichValue(this.input); } diff --git a/src/components/popups/createPoll.ts b/src/components/popups/createPoll.ts index 292c630a..07aa803f 100644 --- a/src/components/popups/createPoll.ts +++ b/src/components/popups/createPoll.ts @@ -185,7 +185,7 @@ export default class PopupCreatePoll extends PopupElement { private getFilledAnswers() { const answers = Array.from(this.questions.children).map((el, idx) => { const input = el.querySelector('.input-field-input') as HTMLElement; - return input instanceof HTMLInputElement ? input.value : getRichValue(input); + return input instanceof HTMLInputElement ? input.value : getRichValue(input, false).value; }).filter(v => !!v.trim()); return answers; @@ -219,9 +219,8 @@ export default class PopupCreatePoll extends PopupElement { return false; } - const quizSolutionEntities: MessageEntity[] = []; - const quizSolution = getRichValue(this.quizSolutionField.input, quizSolutionEntities) || undefined; - if(quizSolution?.length > MAX_LENGTH_SOLUTION) { + const {value: quizSolution} = getRichValue(this.quizSolutionField.input, false); + if(quizSolution.length > MAX_LENGTH_SOLUTION) { return false; } @@ -238,8 +237,7 @@ export default class PopupCreatePoll extends PopupElement { const answers = this.getFilledAnswers(); - const quizSolutionEntities: MessageEntity[] = []; - const quizSolution = getRichValue(this.quizSolutionField.input, quizSolutionEntities) || undefined; + const {value: quizSolution, entities: quizSolutionEntities} = getRichValue(this.quizSolutionField.input); if(this.chat.type === 'scheduled' && !force) { this.chat.input.scheduleSending(() => { diff --git a/src/helpers/dom/attachListNavigation.ts b/src/helpers/dom/attachListNavigation.ts new file mode 100644 index 00000000..2139d586 --- /dev/null +++ b/src/helpers/dom/attachListNavigation.ts @@ -0,0 +1,160 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +import fastSmoothScroll from "../fastSmoothScroll"; +import { cancelEvent } from "./cancelEvent"; +import { attachClickEvent, detachClickEvent } from "./clickEvent"; +import findUpAsChild from "./findUpAsChild"; +import findUpClassName from "./findUpClassName"; + +type ArrowKey = 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight'; +const HANDLE_EVENT = 'keydown'; +const ACTIVE_CLASS_NAME = 'active'; + +export default function attachListNavigation({list, type, onSelect, once}: { + list: HTMLElement, + type: 'xy' | 'x' | 'y', + onSelect: (target: Element) => void | boolean, + once: boolean, +}) { + const keyNames: Set = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']); + + let target: Element; + const getCurrentTarget = () => { + return target || list.querySelector('.' + ACTIVE_CLASS_NAME) || list.firstElementChild; + }; + + const setCurrentTarget = (_target: Element) => { + if(target === _target) { + return; + } + + let hadTarget = false; + if(target) { + hadTarget = true; + target.classList.remove(ACTIVE_CLASS_NAME); + } + + target = _target; + target.classList.add(ACTIVE_CLASS_NAME); + + if(hadTarget && scrollable) { + fastSmoothScroll(scrollable, target as HTMLElement, 'center', undefined, undefined, undefined, 100, type === 'x' ? 'x' : 'y'); + } + }; + + const getNextTargetX = (currentTarget: Element, isNext: boolean) => { + let nextTarget: Element; + if(isNext) nextTarget = currentTarget.nextElementSibling || list.firstElementChild; + else nextTarget = currentTarget.previousElementSibling || list.lastElementChild; + + return nextTarget; + }; + + const getNextTargetY = (currentTarget: Element, isNext: boolean) => { + const property = isNext ? 'nextElementSibling' : 'previousElementSibling'; + const endProperty = isNext ? 'firstElementChild' : 'lastElementChild'; + const currentRect = currentTarget.getBoundingClientRect(); + + let nextTarget = currentTarget[property] || list[endProperty]; + while(nextTarget !== currentTarget) { + const targetRect = nextTarget.getBoundingClientRect(); + if(targetRect.x === currentRect.x && targetRect.y !== currentRect.y) { + break; + } + + nextTarget = nextTarget[property] || list[endProperty]; + } + + return nextTarget; + }; + + let handleArrowKey: (currentTarget: Element, key: ArrowKey) => Element; + if(type === 'xy') { // flex-direction: row; flex-wrap: wrap; + handleArrowKey = (currentTarget, key) => { + if(key === 'ArrowUp' || key === 'ArrowDown') return getNextTargetY(currentTarget, key === 'ArrowDown'); + else return getNextTargetX(currentTarget, key === 'ArrowRight'); + }; + } else { // flex-direction: row | column; + handleArrowKey = (currentTarget, key) => getNextTargetX(currentTarget, key === 'ArrowRight' || key === 'ArrowDown'); + } + + const onKeyDown = (e: KeyboardEvent) => { + if(!keyNames.has(e.key as any)) { + if(e.key === 'Enter') { + cancelEvent(e); + fireSelect(getCurrentTarget()); + } + + return; + } + + cancelEvent(e); + + if(list.childElementCount > 1) { + let currentTarget = getCurrentTarget(); + currentTarget = handleArrowKey(currentTarget, e.key as any); + setCurrentTarget(currentTarget); + } + + return false; + }; + + const scrollable = findUpClassName(list, 'scrollable'); + list.classList.add('navigable-list'); + + const onMouseMove = (e: MouseEvent) => { + const target = findUpAsChild(e.target, list) as HTMLElement; + if(!target) { + return; + } + + setCurrentTarget(target); + }; + + const onClick = (e: Event) => { + cancelEvent(e); // cancel keyboard closening + + const target = findUpAsChild(e.target, list) as HTMLElement; + if(!target) { + return; + } + + setCurrentTarget(target); + fireSelect(getCurrentTarget()); + }; + + const fireSelect = (target: Element) => { + const canContinue = onSelect(target); + if(canContinue !== undefined ? !canContinue : once) { + detach(); + } + }; + + const detach = () => { + // input.removeEventListener(HANDLE_EVENT, onKeyDown, {capture: true}); + document.removeEventListener(HANDLE_EVENT, onKeyDown, {capture: true}); + list.removeEventListener('mousemove', onMouseMove); + detachClickEvent(list, onClick); + }; + + const resetTarget = () => { + setCurrentTarget(list.firstElementChild); + }; + + resetTarget(); + + // const input = document.activeElement as HTMLElement; + // input.addEventListener(HANDLE_EVENT, onKeyDown, {capture: true, passive: false}); + document.addEventListener(HANDLE_EVENT, onKeyDown, {capture: true, passive: false}); + list.addEventListener('mousemove', onMouseMove, {passive: true}); + attachClickEvent(list, onClick); + + return { + detach, + resetTarget + }; +} diff --git a/src/helpers/dom/getRichElementValue.ts b/src/helpers/dom/getRichElementValue.ts new file mode 100644 index 00000000..1b69c945 --- /dev/null +++ b/src/helpers/dom/getRichElementValue.ts @@ -0,0 +1,123 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + * + * Originally from: + * https://github.com/zhukov/webogram + * Copyright (C) 2014 Igor Zhukov + * https://github.com/zhukov/webogram/blob/master/LICENSE + */ + +import { MessageEntity } from "../../layer"; + +export type MarkdownType = 'bold' | 'italic' | 'underline' | 'strikethrough' | 'monospace' | 'link'; +export type MarkdownTag = { + match: string, + entityName: 'messageEntityBold' | 'messageEntityUnderline' | 'messageEntityItalic' | 'messageEntityPre' | 'messageEntityStrike' | 'messageEntityTextUrl'; +}; +export const markdownTags: {[type in MarkdownType]: MarkdownTag} = { + bold: { + match: '[style*="font-weight"], b', + entityName: 'messageEntityBold' + }, + underline: { + match: '[style*="underline"], u', + entityName: 'messageEntityUnderline' + }, + italic: { + match: '[style*="italic"], i', + entityName: 'messageEntityItalic' + }, + monospace: { + match: '[style*="monospace"], [face="monospace"]', + entityName: 'messageEntityPre' + }, + strikethrough: { + match: '[style*="line-through"], strike', + entityName: 'messageEntityStrike' + }, + link: { + match: 'A', + entityName: 'messageEntityTextUrl' + } +}; + +export default function getRichElementValue(node: HTMLElement, lines: string[], line: string[], selNode?: Node, selOffset?: number, entities?: MessageEntity[], offset = {offset: 0}) { + if(node.nodeType === 3) { // TEXT + if(selNode === node) { + const value = node.nodeValue; + line.push(value.substr(0, selOffset) + '\x01' + value.substr(selOffset)); + } else { + const nodeValue = node.nodeValue; + line.push(nodeValue); + + if(entities && nodeValue.trim()) { + if(node.parentNode) { + const parentElement = node.parentElement; + + for(const type in markdownTags) { + const tag = markdownTags[type as MarkdownType]; + const closest = parentElement.closest(tag.match + ', [contenteditable]'); + if(closest && closest.getAttribute('contenteditable') === null) { + if(tag.entityName === 'messageEntityTextUrl') { + entities.push({ + _: tag.entityName as any, + url: (parentElement as HTMLAnchorElement).href, + offset: offset.offset, + length: nodeValue.length + }); + } else { + entities.push({ + _: tag.entityName as any, + offset: offset.offset, + length: nodeValue.length + }); + } + } + } + } + } + + offset.offset += nodeValue.length; + } + + return; + } + + if(node.nodeType !== 1) { // NON-ELEMENT + return; + } + + const isSelected = (selNode === node); + const isBlock = node.tagName === 'DIV' || node.tagName === 'P'; + if(isBlock && line.length || node.tagName === 'BR') { + lines.push(line.join('')); + line.splice(0, line.length); + } else if(node.tagName === 'IMG') { + const alt = (node as HTMLImageElement).alt; + if(alt) { + line.push(alt); + offset.offset += alt.length; + } + } + + if(isSelected && !selOffset) { + line.push('\x01'); + } + + let curChild = node.firstChild as HTMLElement; + while(curChild) { + getRichElementValue(curChild, lines, line, selNode, selOffset, entities, offset); + curChild = curChild.nextSibling as any; + } + + if(isSelected && selOffset) { + line.push('\x01'); + } + + if(isBlock && line.length) { + lines.push(line.join('')); + line.splice(0, line.length); + } +} diff --git a/src/helpers/dom/getRichValue.ts b/src/helpers/dom/getRichValue.ts index 1f91b36b..4d45f72f 100644 --- a/src/helpers/dom/getRichValue.ts +++ b/src/helpers/dom/getRichValue.ts @@ -12,15 +12,13 @@ import { MOUNT_CLASS_TO } from "../../config/debug"; import { MessageEntity } from "../../layer"; import RichTextProcessor from "../../lib/richtextprocessor"; +import getRichElementValue from "./getRichElementValue"; -export default function getRichValue(field: HTMLElement, entities?: MessageEntity[]) { - if(!field) { - return ''; - } - +export default function getRichValue(field: HTMLElement, withEntities = true) { const lines: string[] = []; const line: string[] = []; + const entities: MessageEntity[] = withEntities ? [] : undefined; getRichElementValue(field, lines, line, undefined, undefined, entities); if(line.length) { lines.push(line.join('')); @@ -35,118 +33,7 @@ export default function getRichValue(field: HTMLElement, entities?: MessageEntit //console.log('getRichValue:', value, entities); - return value; + return {value, entities}; } MOUNT_CLASS_TO.getRichValue = getRichValue; - -export type MarkdownType = 'bold' | 'italic' | 'underline' | 'strikethrough' | 'monospace' | 'link'; -export type MarkdownTag = { - match: string, - entityName: 'messageEntityBold' | 'messageEntityUnderline' | 'messageEntityItalic' | 'messageEntityPre' | 'messageEntityStrike' | 'messageEntityTextUrl'; -}; -export const markdownTags: {[type in MarkdownType]: MarkdownTag} = { - bold: { - match: '[style*="font-weight"], b', - entityName: 'messageEntityBold' - }, - underline: { - match: '[style*="underline"], u', - entityName: 'messageEntityUnderline' - }, - italic: { - match: '[style*="italic"], i', - entityName: 'messageEntityItalic' - }, - monospace: { - match: '[style*="monospace"], [face="monospace"]', - entityName: 'messageEntityPre' - }, - strikethrough: { - match: '[style*="line-through"], strike', - entityName: 'messageEntityStrike' - }, - link: { - match: 'A', - entityName: 'messageEntityTextUrl' - } -}; - -function getRichElementValue(node: HTMLElement, lines: string[], line: string[], selNode?: Node, selOffset?: number, entities?: MessageEntity[], offset = {offset: 0}) { - if(node.nodeType === 3) { // TEXT - if(selNode === node) { - const value = node.nodeValue; - line.push(value.substr(0, selOffset) + '\x01' + value.substr(selOffset)); - } else { - const nodeValue = node.nodeValue; - line.push(nodeValue); - - if(entities && nodeValue.trim()) { - if(node.parentNode) { - const parentElement = node.parentElement; - - for(const type in markdownTags) { - const tag = markdownTags[type as MarkdownType]; - const closest = parentElement.closest(tag.match + ', [contenteditable]'); - if(closest && closest.getAttribute('contenteditable') === null) { - if(tag.entityName === 'messageEntityTextUrl') { - entities.push({ - _: tag.entityName as any, - url: (parentElement as HTMLAnchorElement).href, - offset: offset.offset, - length: nodeValue.length - }); - } else { - entities.push({ - _: tag.entityName as any, - offset: offset.offset, - length: nodeValue.length - }); - } - } - } - } - } - - offset.offset += nodeValue.length; - } - - return; - } - - if(node.nodeType !== 1) { // NON-ELEMENT - return; - } - - const isSelected = (selNode === node); - const isBlock = node.tagName === 'DIV' || node.tagName === 'P'; - if(isBlock && line.length || node.tagName === 'BR') { - lines.push(line.join('')); - line.splice(0, line.length); - } else if(node.tagName === 'IMG') { - const alt = (node as HTMLImageElement).alt; - if(alt) { - line.push(alt); - offset.offset += alt.length; - } - } - - if(isSelected && !selOffset) { - line.push('\x01'); - } - - let curChild = node.firstChild as HTMLElement; - while(curChild) { - getRichElementValue(curChild, lines, line, selNode, selOffset, entities, offset); - curChild = curChild.nextSibling as any; - } - - if(isSelected && selOffset) { - line.push('\x01'); - } - - if(isBlock && line.length) { - lines.push(line.join('')); - line.splice(0, line.length); - } -} diff --git a/src/helpers/dom/getRichValueWithCaret.ts b/src/helpers/dom/getRichValueWithCaret.ts new file mode 100644 index 00000000..55ee1219 --- /dev/null +++ b/src/helpers/dom/getRichValueWithCaret.ts @@ -0,0 +1,52 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + * + * Originally from: + * https://github.com/zhukov/webogram + * Copyright (C) 2014 Igor Zhukov + * https://github.com/zhukov/webogram/blob/master/LICENSE + */ + +import { MessageEntity } from "../../layer"; +import RichTextProcessor from "../../lib/richtextprocessor"; +import getRichElementValue from "./getRichElementValue"; + +export default function getRichValueWithCaret(field: HTMLElement, withEntities = true) { + const lines: string[] = []; + const line: string[] = []; + + const sel = window.getSelection(); + var selNode + var selOffset + if(sel && sel.rangeCount) { + const range = sel.getRangeAt(0); + if(range.startContainer && + range.startContainer == range.endContainer && + range.startOffset == range.endOffset) { + selNode = range.startContainer; + selOffset = range.startOffset; + } + } + + const entities: MessageEntity[] = withEntities ? [] : undefined; + getRichElementValue(field, lines, line, selNode, selOffset, entities); + + if(line.length) { + lines.push(line.join('')); + } + + let value = lines.join('\n'); + const caretPos = value.indexOf('\x01'); + if(caretPos != -1) { + value = value.substr(0, caretPos) + value.substr(caretPos + 1); + } + value = value.replace(/\u00A0/g, ' '); + + if(entities) { + RichTextProcessor.combineSameEntities(entities); + } + + return {value, entities, caretPos}; +} diff --git a/src/helpers/dom/isInputEmpty.ts b/src/helpers/dom/isInputEmpty.ts index 0e16732b..4015f81a 100644 --- a/src/helpers/dom/isInputEmpty.ts +++ b/src/helpers/dom/isInputEmpty.ts @@ -11,7 +11,7 @@ export default function isInputEmpty(element: HTMLElement) { /* const value = element.innerText; return !value.trim() && !serializeNodes(Array.from(element.childNodes)).trim(); */ - return !getRichValue(element).trim(); + return !getRichValue(element, false).value.trim(); } else { return !(element as HTMLInputElement).value.trim(); } diff --git a/src/lib/appManagers/appPollsManager.ts b/src/lib/appManagers/appPollsManager.ts index 1eb19aac..3c70e2a3 100644 --- a/src/lib/appManagers/appPollsManager.ts +++ b/src/lib/appManagers/appPollsManager.ts @@ -148,6 +148,8 @@ export class AppPollsManager { } solution = RichTextProcessor.parseMarkdown(solution, solutionEntities); + } else { + solution = undefined; // can be string here } return { diff --git a/src/scss/partials/_autocompleteHelper.scss b/src/scss/partials/_autocompleteHelper.scss new file mode 100644 index 00000000..de104ffd --- /dev/null +++ b/src/scss/partials/_autocompleteHelper.scss @@ -0,0 +1,28 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +.autocomplete-helper { + --border-radius: #{$border-radius-medium}; + position: absolute !important; + bottom: calc(100% + .625rem); + overflow: hidden; + padding: 0 !important; + border-radius: var(--border-radius) !important; + + &:not(.is-visible) { + display: none; + } + + @include animation-level(2) { + &.is-visible { + animation: fade-out-opacity .2s ease-in-out forwards; + + &:not(.backwards) { + animation: fade-in-opacity .2s ease-in-out forwards; + } + } + } +} diff --git a/src/scss/partials/_chatStickersHelper.scss b/src/scss/partials/_chatStickersHelper.scss index f75ff4e7..14ffbf5d 100644 --- a/src/scss/partials/_chatStickersHelper.scss +++ b/src/scss/partials/_chatStickersHelper.scss @@ -5,47 +5,22 @@ */ .stickers-helper { - position: absolute !important; - bottom: calc(100% + 10px); - overflow: hidden; - padding: 0 !important; - border-radius: 10px !important; - > .scrollable { position: relative; - max-height: 220px; + max-height: 13.75rem; min-height: var(--esg-sticker-size); - padding: 7px; + padding: .4375rem; } &-stickers { display: flex; flex-wrap: wrap; + border-radius: var(--border-radius); } - &-sticker { - position: relative; - width: var(--esg-sticker-size); - height: var(--esg-sticker-size); - margin: 5px; - - img { - width: 100%; - height: 100%; - } - } - - &:not(.is-visible) { - display: none; - } - - @include animation-level(2) { - &.is-visible { - animation: fade-out-opacity .2s ease-in-out forwards; - - &:not(.backwards) { - animation: fade-in-opacity .2s ease-in-out forwards; - } + .super-sticker:not(.active) { + @include hover() { + background: none; } } } diff --git a/src/scss/style.scss b/src/scss/style.scss index b0f00da2..ce51cef7 100644 --- a/src/scss/style.scss +++ b/src/scss/style.scss @@ -225,6 +225,7 @@ html.night { @import "partials/input"; @import "partials/button"; @import "partials/animatedIcon"; +@import "partials/autocompleteHelper"; @import "partials/badge"; @import "partials/checkbox"; @import "partials/chatlist"; @@ -1205,3 +1206,10 @@ middle-ellipsis-element { .verified-background { fill: #33a8e5; } + +.navigable-list { + .active { + background-color: var(--light-secondary-text-color); + border-radius: inherit; + } +}