Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implement lettercase feature #2461

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@
"redux-thunk": "^2.4.2",
"reselect": "^5.1.1",
"text-block-parser": "^1.1.1",
"title-case": "^4.3.2",
"truncate-html": "^1.1.2",
"ts-key-enum": "^2.0.12",
"use-onclickoutside": "^0.4.1",
Expand Down
4 changes: 4 additions & 0 deletions src/@types/LetterCaseType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** Set of letter case types supported for letter case picker. */
type LetterCaseType = 'LowerCase' | 'UpperCase' | 'SentenceCase' | 'TitleCase'

export default LetterCaseType
1 change: 1 addition & 0 deletions src/@types/State.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ interface State {
search: string | null
searchContexts: Index<Context> | null
searchLimit?: number
showLetterCase?: boolean
showColorPicker?: boolean
showCommandPalette: boolean
showHiddenThoughts: boolean
Expand Down
37 changes: 37 additions & 0 deletions src/actions/formatLetterCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/* eslint-disable import/prefer-default-export */
import LetterCaseType from '../@types/LetterCaseType'
import Thunk from '../@types/Thunk'
import * as selection from '../device/selection'
import pathToThought from '../selectors/pathToThought'
import simplifyPath from '../selectors/simplifyPath'
import applyLetterCase from '../util/applyLetterCase'
import { editThoughtActionCreator as editThought } from './editThought'

/** Format the browser selection or cursor thought based on the specified letter case change. */
export const formatLetterCaseActionCreator =
(command: LetterCaseType): Thunk =>
(dispatch, getState) => {
const state = getState()
const cursor = state.cursor
if (!cursor) return

const thought = pathToThought(state, cursor)
const originalThoughtValue = thought.value

const updatedThoughtValue = applyLetterCase(command, originalThoughtValue)
const simplePath = simplifyPath(state, cursor)

const savedSelection = selection.save()

dispatch(
editThought({
oldValue: originalThoughtValue,
newValue: updatedThoughtValue,
path: simplePath,
force: true,
}),
)
if (!savedSelection) return
savedSelection.node.textContent = updatedThoughtValue
selection.restore(savedSelection)
}
1 change: 1 addition & 0 deletions src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export { default as swapParent } from './swapParent'
export { default as toggleAbsoluteContext } from './toggleAbsoluteContext'
export { default as toggleAttribute } from './toggleAttribute'
export { default as toggleColorPicker } from './toggleColorPicker'
export { default as toggleLetterCase } from './toggleLetterCase'
export { default as toggleContextView } from './toggleContextView'
export { default as toggleHiddenThoughts } from './toggleHiddenThoughts'
export { default as toggleMulticursor } from './toggleMulticursor'
Expand Down
2 changes: 1 addition & 1 deletion src/actions/setCursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ const setCursor = (
multicursors: {},
}
: null),
...(!thoughtsResolved ? { showColorPicker: false } : null),
...(!thoughtsResolved ? { showColorPicker: false, showLetterCase: false } : null),
}

return stateNew
Expand Down
17 changes: 17 additions & 0 deletions src/actions/toggleLetterCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import _ from 'lodash'
import State from '../@types/State'
import Thunk from '../@types/Thunk'

/** Toggles the ColorPicker. */
RED-ROSE515 marked this conversation as resolved.
Show resolved Hide resolved
const toggleLetterCase = (state: State, { value }: { value?: boolean }) => ({
...state,
showLetterCase: value == null ? !state.showLetterCase : value,
})

/** Action-creator for toggleColorPicker. */
RED-ROSE515 marked this conversation as resolved.
Show resolved Hide resolved
export const toggleLetterCaseActionCreator =
(payload: Parameters<typeof toggleLetterCase>[1]): Thunk =>
dispatch =>
dispatch({ type: 'toggleLetterCase', ...payload })

export default _.curryRight(toggleLetterCase)
23 changes: 2 additions & 21 deletions src/components/ColorPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { rgbToHex } from '@mui/material'
import React, { FC, useLayoutEffect, useRef, useState } from 'react'
import React, { FC, useRef } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { formatSelectionActionCreator as formatSelection } from '../actions/formatSelection'
import { isTouch } from '../browser'
import * as selection from '../device/selection'
import useWindowOverflow from '../hooks/useWindowOverflow'
import getThoughtById from '../selectors/getThoughtById'
import themeColors from '../selectors/themeColors'
import commandStateStore from '../stores/commandStateStore'
Expand All @@ -15,26 +16,6 @@ import TextColorIcon from './icons/TextColor'
/** A function that adds an alpha channel to a hex color. */
const addAlphaToHex = (hex: string) => (hex.length === 7 ? hex + 'ff' : hex)

/** A hook that returns the left and right overflow of the element outside the bounds of the screen. Do not re-calculate on every render or it will create an infinite loop when scrolling the Toolbar. */
const useWindowOverflow = (ref: React.RefObject<HTMLElement>) => {
const [overflow, setOverflow] = useState({ left: 0, right: 0 })

useLayoutEffect(() => {
if (!ref.current) return
const rect = ref.current.getBoundingClientRect()
// Subtract the previous overflow, since that affects the client rect.
// Otherwise the overflow will alternate on each render as it moves on and off the screen.
const left = Math.max(0, -rect.x + 15 - overflow.left)
// add 10px for padding
const right = Math.max(0, rect.x + rect.width - window.innerWidth + 10 - overflow.right)
if (left > 0 || right > 0) {
setOverflow({ left, right })
}
}, [ref, overflow.left, overflow.right])

return overflow
}

/** A small, square color swatch that can be picked in the color picker. */
const ColorSwatch: FC<{
backgroundColor?: string
Expand Down
2 changes: 2 additions & 0 deletions src/components/Content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Thunk } from '../@types/Thunk'
import { closeModalActionCreator as closeModal } from '../actions/closeModal'
import { expandContextThoughtActionCreator as expandContextThought } from '../actions/expandContextThought'
import { toggleColorPickerActionCreator as toggleColorPicker } from '../actions/toggleColorPicker'
import { toggleLetterCaseActionCreator as toggleLetterCase } from '../actions/toggleLetterCase'
import { isTouch } from '../browser'
import { ABSOLUTE_PATH, HOME_PATH, TUTORIAL2_STEP_SUCCESS } from '../constants'
import * as selection from '../device/selection'
Expand Down Expand Up @@ -58,6 +59,7 @@ const Content: FC = () => {
setIsPressed(false)

dispatch([state.showColorPicker ? toggleColorPicker({ value: false }) : null])
dispatch([state.showLetterCase ? toggleLetterCase({ value: false }) : null])

// web only
// click event occured during text selection has focus node of type text unlike normal event which has node of type element
Expand Down
7 changes: 4 additions & 3 deletions src/components/Editable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { setInvalidStateActionCreator as setInvalidState } from '../actions/inva
import { newThoughtActionCreator as newThought } from '../actions/newThought'
import { setCursorActionCreator as setCursor } from '../actions/setCursor'
import { toggleColorPickerActionCreator as toggleColorPicker } from '../actions/toggleColorPicker'
import { toggleLetterCaseActionCreator as toggleLetterCase } from '../actions/toggleLetterCase'
import { tutorialNextActionCreator as tutorialNext } from '../actions/tutorialNext'
import { isIOS, isMac, isSafari, isTouch } from '../browser'
import {
Expand Down Expand Up @@ -556,9 +557,9 @@ const Editable = ({
if (!isVisible) {
selection.clear()

if (state.showColorPicker) {
dispatch(toggleColorPicker({ value: false }))
}
if (state.showColorPicker) dispatch(toggleColorPicker({ value: false }))

if (state.showLetterCase) dispatch(toggleLetterCase({ value: false }))
} else {
setCursorOnThought()

Expand Down
94 changes: 94 additions & 0 deletions src/components/LetterCasePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import React, { FC, memo, useRef } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import LetterCaseType from '../@types/LetterCaseType'
import { formatLetterCaseActionCreator as formatLetterCase } from '../actions/formatLetterCase'
import { isTouch } from '../browser'
import useWindowOverflow from '../hooks/useWindowOverflow'
import getThoughtById from '../selectors/getThoughtById'
import themeColors from '../selectors/themeColors'
import applyLetterCase from '../util/applyLetterCase'
import fastClick from '../util/fastClick'
import head from '../util/head'
import TriangleDown from './TriangleDown'
import LowerCaseIcon from './icons/LowerCaseIcon'
import SentenceCaseIcon from './icons/SentenceCaseIcon'
import TitleCaseIcon from './icons/TitleCaseIcon'
import UpperCaseIcon from './icons/UpperCaseIcon'

/** Letter Case Picker component. */
const LetterCasePicker: FC<{ fontSize: number; style?: React.CSSProperties }> = memo(({ fontSize, style }) => {
const colors = useSelector(themeColors)
const ref = useRef<HTMLDivElement>(null)
const dispatch = useDispatch()
const overflow = useWindowOverflow(ref)

/** Toggles the Letter Case to the clicked swatch. */
const toggleLetterCase = (command: LetterCaseType, e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation()
e.preventDefault()
dispatch(formatLetterCase(command))
}
const selected = useSelector(state => {
const value = (!!state.cursor && getThoughtById(state, head(state.cursor))?.value) || ''
if (value === applyLetterCase('LowerCase', value)) return 'LowerCase'
if (value === applyLetterCase('UpperCase', value)) return 'UpperCase'
if (value === applyLetterCase('SentenceCase', value)) return 'SentenceCase'
if (value === applyLetterCase('TitleCase', value)) return 'TitleCase'
return ''
})
const casingTypes: LetterCaseType[] = ['LowerCase', 'UpperCase', 'SentenceCase', 'TitleCase']

return (
<div style={{ userSelect: 'none' }}>
<div
ref={ref}
style={{
backgroundColor: colors.fgOverlay90,
borderRadius: 3,
display: 'inline-block',
padding: '0.2em 0.25em 0.25em',
position: 'relative',
...(overflow.left ? { left: overflow.left } : { right: overflow.right }),
...style,
}}
>
<TriangleDown
fill={colors.fgOverlay90}
size={fontSize}
style={{
position: 'absolute',
RED-ROSE515 marked this conversation as resolved.
Show resolved Hide resolved
...(overflow.left ? { left: -overflow.left } : { right: -overflow.right }),
top: -fontSize / 2,
width: '100%',
}}
/>

<div aria-label='letter case swatches' style={{ whiteSpace: 'wrap' }}>
{casingTypes.map(type => (
<div
key={type}
title={type}
style={{
border: `solid 1px ${selected === type ? colors.fg : 'transparent'}`,
margin: '2px',
lineHeight: 0,
}}
aria-label={type}
{...fastClick(e => e.stopPropagation())}
onTouchStart={e => toggleLetterCase(type, e)}
onMouseDown={e => !isTouch && toggleLetterCase(type, e)}
>
{type === 'LowerCase' && <LowerCaseIcon />}
{type === 'UpperCase' && <UpperCaseIcon />}
{type === 'SentenceCase' && <SentenceCaseIcon />}
{type === 'TitleCase' && <TitleCaseIcon />}
</div>
))}
</div>
</div>
</div>
)
})
LetterCasePicker.displayName = 'LetterCasePicker'

export default LetterCasePicker
58 changes: 58 additions & 0 deletions src/components/__tests__/LetterCasePicker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { newThoughtActionCreator as newThought } from '../../actions/newThought'
import { HOME_TOKEN } from '../../constants'
import exportContext from '../../selectors/exportContext'
import store from '../../stores/app'
import click from '../../test-helpers/click'
import createTestApp, { cleanupTestApp } from '../../test-helpers/createTestApp'
import dispatch from '../../test-helpers/dispatch'

beforeEach(createTestApp)
afterEach(cleanupTestApp)

it('Set Lower Case to the current thought', async () => {
await dispatch([newThought({ value: 'Hello everyone, this is Rose. Thanks for your help.' })])
await click('[data-testid="toolbar-icon"][aria-label="LetterCase"]')
await click('[aria-label="letter case swatches"] [aria-label="LowerCase"]')

const state = store.getState()

const exported = exportContext(state, [HOME_TOKEN], 'text/plain')
expect(exported).toEqual(`- __ROOT__
- hello everyone, this is rose. thanks for your help.`)
})

it('Set Upper Case to the current thought', async () => {
await dispatch([newThought({ value: 'Hello everyone, this is Rose. Thanks for your help.' })])
await click('[data-testid="toolbar-icon"][aria-label="LetterCase"]')
await click('[aria-label="letter case swatches"] [aria-label="UpperCase"]')

const state = store.getState()

const exported = exportContext(state, [HOME_TOKEN], 'text/plain')
expect(exported).toEqual(`- __ROOT__
- HELLO EVERYONE, THIS IS ROSE. THANKS FOR YOUR HELP.`)
})

it('Set Sentence Case to the current thought', async () => {
await dispatch([newThought({ value: 'Hello everyone, this is Rose. Thanks for your help.' })])
await click('[data-testid="toolbar-icon"][aria-label="LetterCase"]')
await click('[aria-label="letter case swatches"] [aria-label="SentenceCase"]')

const state = store.getState()

const exported = exportContext(state, [HOME_TOKEN], 'text/plain')
expect(exported).toEqual(`- __ROOT__
- Hello everyone, this is rose. Thanks for your help.`)
})

it('Set Title Case to the current thought', async () => {
await dispatch([newThought({ value: 'Hello everyone, this is Rose. Thanks for your help.' })])
await click('[data-testid="toolbar-icon"][aria-label="LetterCase"]')
await click('[aria-label="letter case swatches"] [aria-label="TitleCase"]')

const state = store.getState()

const exported = exportContext(state, [HOME_TOKEN], 'text/plain')
expect(exported).toEqual(`- __ROOT__
- Hello Everyone, This Is Rose. Thanks for Your Help.`)
})
33 changes: 33 additions & 0 deletions src/components/icons/LetterCaseIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { css, cx } from '../../../styled-system/css'
import { icon } from '../../../styled-system/recipes'
import { token } from '../../../styled-system/tokens'
import IconType from '../../@types/Icon'
import { ICON_SCALING_FACTOR } from '../../constants'

/** Letter-case icon. */
const LetterCaseIcon = ({ fill, size = 20, style = {}, cssRaw }: IconType) => {
const newSize = size * ICON_SCALING_FACTOR // Ensure sizing follows scaling factor
const strokeColor = style.fill || fill || token('colors.fg') // Calculate stroke color

return (
<svg
className={cx(icon(), css(cssRaw))} // Combine class names
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24' // Keep the original viewBox
style={{ ...style, width: `${newSize}px`, height: `${newSize}px` }} // Inline styles
fill='none'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
stroke={strokeColor}
>
<path stroke='none' d='M0 0h24v24H0z' fill='none' />
<path fill='none' d='M17.5 15.5m-3.5 0a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0 -7 0' />
<path fill='none' d='M3 19v-10.5a3.5 3.5 0 0 1 7 0v10.5' />
<path fill='none' d='M3 13h7' />
<path fill='none' d='M21 12v7' />
</svg>
)
}

export default LetterCaseIcon
Loading