Skip to content

Commit

Permalink
Add non-bip39 word underlining in mnemonic field
Browse files Browse the repository at this point in the history
  • Loading branch information
DavidTranDucVL committed Dec 2, 2020
1 parent 59caab2 commit 3eda000
Show file tree
Hide file tree
Showing 9 changed files with 304 additions and 45 deletions.
4 changes: 2 additions & 2 deletions app/frontend/actions.ts
Expand Up @@ -314,8 +314,8 @@ export default ({setState, getState}: {setState: SetStateFn; getState: GetStateF
})
}

const updateMnemonic = (state: State, e) => {
const mnemonicInputValue = e.target.value
const updateMnemonic = (state: State, mnemonic) => {
const mnemonicInputValue = mnemonic
const sanitizedMnemonic = sanitizeMnemonic(mnemonicInputValue)
const formIsValid = sanitizedMnemonic && mnemonicValidator(sanitizedMnemonic) === null

Expand Down
5 changes: 1 addition & 4 deletions app/frontend/components/pages/login/loginPage.tsx
Expand Up @@ -176,10 +176,7 @@ class LoginPage extends Component<Props, {isDropdownOpen: boolean}> {
<main className="page-main">
{authMethod === '' ? <AuthCardInitial /> : <AuthCard />}
<div className="page-demo">
Try the{' '}
<a href="#" onMouseDown={(e) => isLeftClick(e, loadDemoWallet)}>
demo wallet
</a>
Try the <a onMouseDown={(e) => isLeftClick(e, loadDemoWallet)}>demo wallet</a>
</div>
</main>
<LoginPageSidebar />
Expand Down
48 changes: 10 additions & 38 deletions app/frontend/components/pages/login/mnemonicAuth.tsx
Expand Up @@ -8,34 +8,22 @@ import tooltip from '../../common/tooltip'
import Alert from '../../common/alert'
import sanitizeMnemonic from '../../../helpers/sanitizeMnemonic'
import {ADALITE_CONFIG} from '../../../config'
import MnemonicField from './mnemonicField'

const {ADALITE_DEMO_WALLET_MNEMONIC} = ADALITE_CONFIG

interface Props {
formData: any
updateMnemonic: (e: any) => void
updateMnemonicValidationError: () => void
//
loadWallet: any
shouldShowMnemonicInfoAlert: boolean
openGenerateMnemonicDialog: () => void
autoLogin: boolean
displayWelcome: boolean
shouldShowDemoWalletWarningDialog: boolean
}

class LoadByMnemonicSectionClass extends Component<Props> {
mnemonicField: HTMLInputElement
mnemonicField: any = {}
goBtn: HTMLButtonElement

componentDidUpdate() {
const shouldFormFocus =
!this.props.formData.mnemonicInputValue &&
!this.props.displayWelcome &&
!this.props.shouldShowDemoWalletWarningDialog
shouldFormFocus && this.mnemonicField.focus()
}

// meant only for development in order to speed up the process of unlocking wallet
async autoLogin() {
const sanitizedMnemonic = sanitizeMnemonic(ADALITE_DEMO_WALLET_MNEMONIC)
Expand All @@ -51,14 +39,7 @@ class LoadByMnemonicSectionClass extends Component<Props> {
}
}

render({
formData,
updateMnemonic,
updateMnemonicValidationError,
loadWallet,
shouldShowMnemonicInfoAlert,
openGenerateMnemonicDialog,
}) {
render({formData, loadWallet, shouldShowMnemonicInfoAlert, openGenerateMnemonicDialog}) {
const sanitizedMnemonic = sanitizeMnemonic(formData.mnemonicInputValue)

return (
Expand All @@ -68,23 +49,16 @@ class LoadByMnemonicSectionClass extends Component<Props> {
Here you can use your mnemonic to access your new wallet.
</Alert>
)}
<label className="authentication-label" htmlFor="mnemonic-submitted">
<label className="authentication-label">
Enter the 12, 15, 24 or 27-word wallet mnemonic seed phrase
</label>
<input
type="text"
className="input fullwidth auth"
id="mnemonic-submitted"
name="mnemonic-submitted"
placeholder="Enter your wallet mnemonic"
value={formData.mnemonicInputValue}
onInput={updateMnemonic}
onBlur={updateMnemonicValidationError}
autoComplete="off"
ref={(element) => {
this.mnemonicField = element
<MnemonicField
onEnterKeyDown={(e) => this.goBtn.click()}
onTabKeyDown={(e) => {
e.preventDefault()
this.goBtn.focus()
}}
onKeyDown={(e) => e.key === 'Enter' && this.goBtn.click()}
expose={this.mnemonicField}
/>
<div className="validation-row">
<button
Expand Down Expand Up @@ -137,8 +111,6 @@ class LoadByMnemonicSectionClass extends Component<Props> {
export default connect(
(state) => ({
formData: state.mnemonicAuthForm,
displayWelcome: state.displayWelcome,
shouldShowDemoWalletWarningDialog: state.shouldShowDemoWalletWarningDialog,
shouldShowMnemonicInfoAlert: state.shouldShowMnemonicInfoAlert,
autoLogin: state.autoLogin,
}),
Expand Down
97 changes: 97 additions & 0 deletions app/frontend/components/pages/login/mnemonicField.tsx
@@ -0,0 +1,97 @@
import {h, Component} from 'preact'
import {connect} from '../../../helpers/connect'
import actions from '../../../actions'
import {underlineNonBip39words} from '../../../helpers/dynamicTextFormatter'
import {getCaretPosition, setCaretPosition} from '../../../../frontend/helpers/caretPosition'

interface Props {
formData: any
updateMnemonic: (mnemonic) => void
updateMnemonicValidationError: () => void
onEnterKeyDown: (e) => void
onTabKeyDown: (e) => void
expose
}

class MnemonicField extends Component<Props> {
mnemonicField: HTMLDivElement
lastFormattedMnemonic = ''
lastRawMnemonic = ''

state = {
focus: 'focus',
}

constructor(props) {
super()
props.expose.focus = () => this.mnemonicField && this.mnemonicField.focus()
}

updateMnemonic() {
if (this.lastFormattedMnemonic !== this.mnemonicField.innerHTML) {
const {formattedText, rawText} = underlineNonBip39words(this.mnemonicField.innerHTML)

const caretPosition = getCaretPosition(this.mnemonicField)
this.mnemonicField.innerHTML = `${formattedText}`
setCaretPosition(this.mnemonicField, caretPosition)

this.lastFormattedMnemonic = formattedText
this.lastRawMnemonic = rawText
this.props.updateMnemonic(rawText)
}
}

overwriteMnemonic() {
this.mnemonicField.innerHTML = this.props.formData.mnemonicInputValue
this.updateMnemonic()
setCaretPosition(this.mnemonicField, this.props.formData.mnemonicInputValue.length)
}

componentDidMount() {
this.overwriteMnemonic()
}

componentDidUpdate() {
if (this.props.formData.mnemonicInputValue !== this.lastRawMnemonic) {
this.overwriteMnemonic()
}
}

render({formData, updateMnemonicValidationError, onEnterKeyDown, onTabKeyDown}) {
return (
<div className={`input fullwidth auth ${this.state.focus}`}>
<div
contentEditable
// eslint-disable-next-line react/no-unknown-property
spellcheck={false}
tabIndex={0}
type="text"
className={`mnemonic-text-field ${
formData.mnemonicInputValue.length ? '' : 'mnemonic-placeholder'
}`}
onInput={() => this.updateMnemonic()}
onBlur={() => {
this.setState({focus: ''})
updateMnemonicValidationError()
}}
autoComplete="off"
ref={(element) => {
this.mnemonicField = element
}}
onKeyDown={(e) => {
e.key === 'Enter' && onEnterKeyDown(e)
e.key === 'Tab' && onTabKeyDown(e)
}}
onFocus={() => this.setState({focus: 'focus'})}
/>
</div>
)
}
}

export default connect(
(state) => ({
formData: state.mnemonicAuthForm,
}),
actions
)(MnemonicField)
63 changes: 63 additions & 0 deletions app/frontend/helpers/caretPosition.ts
@@ -0,0 +1,63 @@
const nodeWalk = (node, func) => {
if (node) {
let result = func(node)
for (node = node.firstChild; result !== false && node; node = node.nextSibling) {
result = nodeWalk(node, func)
}
return result
} else {
return false
}
}

const getCaretPosition = (elem) => {
const sel = window.getSelection()
const caretNode = sel.focusNode === elem ? elem.childNodes.item(sel.focusOffset) : sel.focusNode
const offset = sel.focusNode === elem ? 0 : sel.focusOffset

if (elem.contains(caretNode)) {
let length = 0

nodeWalk(elem, (node) => {
if (node === caretNode) {
return false
} else if (node.nodeType === Node.TEXT_NODE && node.textContent) {
length += node.textContent.length
}
return true
})

return length + offset
} else {
return elem.textContent.length
}
}

const setCaretPosition = (elem, position) => {
let targetNode = elem
let length = 0

nodeWalk(elem, (node) => {
targetNode = node

if (node.nodeType === Node.TEXT_NODE && node.textContent) {
if (length + node.textContent.length >= position) {
return false
} else {
length += node.textContent.length
}
}

return true
})

const newRange = document.createRange()
newRange.setStart(targetNode, position - length)
newRange.collapse(true)

const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(newRange)
}

export {getCaretPosition, setCaretPosition}
100 changes: 100 additions & 0 deletions app/frontend/helpers/dynamicTextFormatter.ts
@@ -0,0 +1,100 @@
import {isBip39Word} from '../wallet/mnemonic'
import matchAll from './matchAll'

const underlineNonBip39words = (toFormat) => {
// 1. forbid multiple divs (lines)
toFormat = toFormat.replace(/<\/{0,1}(div|br|p)[^<>]*>/g, '')

// 2. only non-breaking space allowed as whitespace
toFormat = toFormat.replace(/\s|&nbsp;/g, ' ')

// 3. wrap span around words at the start of line
// ^[^<>a-zA-Z]* - find start of line and absence of '<' and '>'
// [a-zA-Z]+ - find word itself
// (\s|[,;])+<span - find next opening tag '<span'
toFormat = toFormat.replace(/(^[^<>a-zA-Z]*)([a-zA-Z]+)((\s|[,;])+<span)/g, '$1<span>$2</span>$3')

// 4. wrap span around words in the middle of line
// <\/span>(\s|[,;])+ - find '</span>' before word
// [a-zA-Z]+ - find word itself
// (\s|[,;])+<span - find '<span' after word
toFormat = toFormat.replace(
/(<\/span>(\s|[,;])+)([a-zA-Z]+)((\s|[,;])+<span)/g,
'$1<span>$3</span>$4'
)

// 5. wrap span around words in the end of the line
// (?<=^|\s|[,;]) - check for delimiter before word
// [a-zA-Z]+ - find word itself
// (?=$|\s|[,;]) - check for delimiter after word
// (?=[^<>]*$) - check for absence of '<' or '>' after word to prevent
// wrapping tags inside span, like <span class="a b c">
toFormat = toFormat.replace(/(?<=^|\s|[,;])[a-zA-Z]+(?=$|\s|[,;])(?=[^<>]*$)/g, '<span>$&</span>')

// 6. split words by whitespace or commas
// (?<!<span) - check if space before word is inside span tag,
// we don't want to split that; eg. <span class="bip-39">
// (?<![>,;]|\s) - check for '>' symbol from previous span tag
// (\s|[,;])+ - find whitespaces or commas
// (?!\s|[<,;]) - check for '<' symbol from next span tag
toFormat = toFormat.replace(/(?<!<span)(?<![>,;]|\s)(\s|[,;])+(?!\s|[<,;])/g, '</span>$&<span>')

// 7. merge words if there is no delimiter between them
toFormat = toFormat.replace(/<\/span><span[^>]*>/g, '')

// 8. append to wrapped word
// <span[^<>]*> - find opening tag <span class="..."> before word
// [a-zA-Z]+ - find word itself
// <\/span> - find closing tag </span> after word
// [a-zA-Z]+ - find text to append
toFormat = toFormat.replace(/(<span[^<>]*>[a-zA-Z]+)(<\/span>)([a-zA-Z]+)/g, '$1$3$2')

// 9. preppend to wrapped word
// [a-zA-Z]+ - find text to prepend
// <span[^<>]*> - find opening tag <span class="..."> before word
// [a-zA-Z]+ - find word itself
// <\/span> - find closing tag </span> after word
toFormat = toFormat.replace(/([a-zA-Z]+)(<span[^<>]*>)([a-zA-Z]+)(<\/span>)/g, '$2$1$3$4')

// 10. extract delimiters outside of spans
// <span[^<>]*> - find opening tag <span class="..."> before word
// (\s|[,;])* - find delimiters to the left of word
// [a-zA-Z]+ - find word itself
// (\s|[,;])* - find delimiters to the right of word
// <\/span> - find closing tag </span> after word
toFormat = toFormat.replace(
/(<span[^<>]*>)(\s|[,;])*([a-zA-Z]+)(\s|[,;])*(<\/span>)/g,
'$2$1$3$5$4'
)

// 11 remove empty spans
toFormat = toFormat.replace(/<span[^>]*><\/span>/g, '')

// 12. translate non-breaking space back to html
// warning: this translates spaces inside span attributes as well,
// but it doesn't matter for the next steps
toFormat = toFormat.replace(/\s/g, '&nbsp;')

// 13. extract raw words into array
// <span[^>]*> - find opening tag
// [^<>]* - find element content
// <\/span>) - find closing tag
const words = matchAll(/<span[^>]*>[^<>]*<\/span>/g, toFormat).map((wrappedWord) =>
wrappedWord.replace(/(<span[^>]*>)([^<>]*)(<\/span>)/g, '$2').replace(/(\s|[,;])/g, '')
)

// 14. reapply style rules to each word
const areWordsBipP39 = words.map((word) => isBip39Word(word)).reverse()
const formattedText = toFormat.replace(
/(<span[^>]*>)([^<>]*)(<\/span>)/g,
(match, p1, p2, p3, offset, string) => {
const style = areWordsBipP39.pop() ? '' : ' class="not-bip-39"'
return `<span${style}>${p2}${p3}`
}
)
const rawText = words.join(' ')

return {formattedText, rawText}
}

export {underlineNonBip39words}
10 changes: 10 additions & 0 deletions app/frontend/helpers/matchAll.ts
@@ -0,0 +1,10 @@
const matchAll = (regex: RegExp, str: string) => {
const words: string[] = []
let it
while ((it = regex.exec(str)) !== null) {
words.push(it[0])
}
return words
}

export default matchAll

0 comments on commit 3eda000

Please sign in to comment.