diff --git a/webroot/js/chat/content-editable.js b/webroot/js/chat/content-editable.js new file mode 100644 index 000000000..40514b8b0 --- /dev/null +++ b/webroot/js/chat/content-editable.js @@ -0,0 +1,131 @@ +/* +Since we can't really import react-contenteditable here, I'm borrowing code for this component from here: +github.com/lovasoa/react-contenteditable/ + +and here: +https://stackoverflow.com/questions/22677931/react-js-onchange-event-for-contenteditable/27255103#27255103 + +*/ + +import { Component, createRef, createElement } from 'https://unpkg.com/preact?module'; + +function replaceCaret(el) { + // Place the caret at the end of the element + const target = document.createTextNode(''); + el.appendChild(target); + // do not move caret if element was not focused + const isTargetFocused = document.activeElement === el; + if (target !== null && target.nodeValue !== null && isTargetFocused) { + var sel = window.getSelection(); + if (sel !== null) { + var range = document.createRange(); + range.setStart(target, target.nodeValue.length); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } + if (el) el.focus(); + } +} + +function normalizeHtml(str) { + return str && str.replace(/ |\u202F|\u00A0/g, ' '); +} + + + +export default class ContentEditable extends Component { + constructor(props) { + super(props); + + this.el = createRef(); + + this.lastHtml = ''; + + this.emitChange = this.emitChange.bind(this); + this.getDOMElement = this.getDOMElement.bind(this); + } + + shouldComponentUpdate(nextProps) { + const { props } = this; + const el = this.getDOMElement(); + + // We need not rerender if the change of props simply reflects the user's edits. + // Rerendering in this case would make the cursor/caret jump + + // Rerender if there is no element yet... (somehow?) + if (!el) return true; + + // ...or if html really changed... (programmatically, not by user edit) + if ( + normalizeHtml(nextProps.html) !== normalizeHtml(el.innerHTML) + ) { + return true; + } + + // Handle additional properties + return props.disabled !== nextProps.disabled || + props.tagName !== nextProps.tagName || + props.className !== nextProps.className || + props.innerRef !== nextProps.innerRef; + } + + + + componentDidUpdate() { + const el = this.getDOMElement(); + if (!el) return; + + // Perhaps React (whose VDOM gets outdated because we often prevent + // rerendering) did not update the DOM. So we update it manually now. + if (this.props.html !== el.innerHTML) { + el.innerHTML = this.props.html; + } + this.lastHtml = this.props.html; + replaceCaret(el); + } + + getDOMElement() { + return (this.props.innerRef && typeof this.props.innerRef !== 'function' ? this.props.innerRef : this.el).current; + } + + + emitChange(originalEvt) { + const el = this.getDOMElement(); + if (!el) return; + + const html = el.innerHTML; + if (this.props.onChange && html !== this.lastHtml) { + // Clone event with Object.assign to avoid + // "Cannot assign to read only property 'target' of object" + const evt = Object.assign({}, originalEvt, { + target: { + value: html + } + }); + this.props.onChange(evt); + } + this.lastHtml = html; + } + + render(props) { + const { html, innerRef } = props; + return createElement( + 'div', + { + ...props, + ref: typeof innerRef === 'function' ? (current) => { + innerRef(current) + this.el.current = current + } : innerRef || this.el, + onInput: this.emitChange, + onBlur: this.props.onBlur || this.emitChange, + onKeyup: this.props.onKeyUp || this.emitChange, + onKeydown: this.props.onKeyDown || this.emitChange, + contentEditable: !this.props.disabled, + dangerouslySetInnerHTML: { __html: html }, + }, + this.props.children, + ); + } +} diff --git a/webroot/js/chat/standalone.js b/webroot/js/chat/standalone.js index 88c710ef7..cb3a8243e 100644 --- a/webroot/js/chat/standalone.js +++ b/webroot/js/chat/standalone.js @@ -1,5 +1,5 @@ import { html, Component } from "https://unpkg.com/htm/preact/index.mjs?module"; -import UserInfo from './user-info.js'; +import UsernameForm from './username.js'; import Chat from './chat.js'; import Websocket from '../websocket.js'; @@ -37,17 +37,19 @@ export default class StandaloneChat extends Component { return ( html`
- <${UserInfo} + <${UsernameForm} username=${username} userAvatarImage=${userAvatarImage} handleUsernameChange=${this.handleUsernameChange} handleChatToggle=${this.handleChatToggle} /> + <${Chat} websocket=${websocket} username=${username} userAvatarImage=${userAvatarImage} - chatEnabled /> + chatEnabled + />
`); } diff --git a/webroot/js/chat/user-info.js b/webroot/js/chat/username.js similarity index 79% rename from webroot/js/chat/user-info.js rename to webroot/js/chat/username.js index 6cd54958f..c18eb93de 100644 --- a/webroot/js/chat/user-info.js +++ b/webroot/js/chat/username.js @@ -6,8 +6,7 @@ const html = htm.bind(h); import { generateAvatar, setLocalStorage } from '../utils.js'; import { KEY_USERNAME, KEY_AVATAR } from '../utils/chat.js'; - -export default class UserInfo extends Component { +export default class UsernameForm extends Component { constructor(props, context) { super(props, context); @@ -83,25 +82,28 @@ export default class UserInfo extends Component { - ${username} + ${username}
- - + /> + + +
- + `); } diff --git a/webroot/js/components.js b/webroot/js/components.js index 8b4d7aa83..959e2aa1d 100644 --- a/webroot/js/components.js +++ b/webroot/js/components.js @@ -1,3 +1,5 @@ +// DELETE THIS FILE LATER. + Vue.component('owncast-footer', { props: { appVersion: { @@ -5,7 +7,7 @@ Vue.component('owncast-footer', { default: '0.1', }, }, - + template: `