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 the code formatting shortcut #2613

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/@types/FormattingCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ enum FormattingCommand {
italic = 'italic',
underline = 'underline',
strikethrough = 'strikethrough',
code = 'code',
foreColor = 'foreColor',
backColor = 'backColor',
}
Expand Down
5 changes: 4 additions & 1 deletion src/actions/formatSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import { editThoughtActionCreator as editThought } from './editThought'

/** Format the browser selection or cursor thought as bold, italic, strikethrough, underline. */
export const formatSelectionActionCreator =
(command: 'bold' | 'italic' | 'strikethrough' | 'underline' | 'foreColor' | 'backColor', color?: ColorToken): Thunk =>
(
command: 'bold' | 'italic' | 'strikethrough' | 'underline' | 'code' | 'foreColor' | 'backColor',
color?: ColorToken,
): Thunk =>
(dispatch, getState) => {
const state = getState()
if (!state.cursor) return
Expand Down
65 changes: 65 additions & 0 deletions src/actions/formatWithTag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/* eslint-disable import/prefer-default-export */
import FormattingCommand from '../@types/FormattingCommand'
import Thunk from '../@types/Thunk'
import * as selection from '../device/selection'
import pathToThought from '../selectors/pathToThought'
import thoughtToPath from '../selectors/thoughtToPath'
import suppressFocusStore from '../stores/suppressFocus'
import getCommandState from '../util/getCommandState'
import strip from '../util/strip'
import { editThoughtActionCreator as editThought } from './editThought'

/** Format the browser selection or cursor thought with the provided tag. */
export const formatWithTagActionCreator =
(tag: FormattingCommand): Thunk =>
(dispatch, getState) => {
const state = getState()
if (!state.cursor) return
const thought = pathToThought(state, state.cursor)
const simplePath = thoughtToPath(state, thought.id)
suppressFocusStore.update(true)

const tagRegExp = new RegExp(`<${tag}[^>]*>|<\/${tag}>`, 'g')

const thoughtSelected =
(selection.text()?.length === 0 && strip(thought.value).length !== 0) ||
selection.text()?.length === strip(thought.value).length

if (thoughtSelected) {
// format the entire thought
const isAllFormatted = getCommandState(thought.value)[tag]
const withoutTag = thought.value.replace(tagRegExp, '')
const newValue = isAllFormatted
? withoutTag // remove tag
: `<${tag}>${withoutTag}</${tag}>` // add tag
dispatch(
editThought({
cursorOffset: selection.offsetThought() ?? undefined,
oldValue: thought.value,
newValue: newValue,
path: simplePath,
force: true,
}),
)
} else {
// format the selection
const selectedText = selection.html()
if (!selectedText) return
const isAllFormatted = getCommandState(selectedText)[tag]
const withoutTag = selectedText.replace(tagRegExp, '')
const newPart = isAllFormatted
? withoutTag // remove tag
: `<${tag}>${withoutTag}</${tag}>` // add tag
const newValue = thought.value.replace(selectedText, newPart)
dispatch(
editThought({
cursorOffset: selection.offsetThought() ?? undefined,
oldValue: selectedText,
newValue: newValue,
path: simplePath,
force: true,
}),
)
}
suppressFocusStore.update(false)
}
26 changes: 26 additions & 0 deletions src/commands/code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Command from '../@types/Command'
import FormattingCommand from '../@types/FormattingCommand'
import { formatWithTagActionCreator as formatWithTag } from '../actions/formatWithTag'
import Icon from '../components/icons/CodeIcon'
import hasMulticursor from '../selectors/hasMulticursor'
import isDocumentEditable from '../util/isDocumentEditable'

/** Toggles formatting of the current browser selection as code. If there is no selection, formats the entire thought. */
const codeShortcut: Command = {
id: 'code',
label: 'Code',
description: 'Formats the current thought or selected text as code.',
descriptionInverse: 'Removes code formatting from the current thought or selected text.',
multicursor: true,
svg: Icon,
keyboard: { key: 'k', meta: true },
canExecute: state => {
return isDocumentEditable() && (!!state.cursor || hasMulticursor(state))
},
exec: dispatch => {
dispatch(formatWithTag(FormattingCommand.code))
},
// The isActive logic for formatting commands is handled differently than other shortcuts because it references the CommandStateStore. This can be found in ToolbarButton (isButtonActive)
}

export default codeShortcut
1 change: 1 addition & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { default as archive, archiveAliases } from './archive'
export { default as bindContext } from './bindContext'
export { default as bumpThoughtDown } from './bumpThoughtDown'
export { default as clearThought } from './clearThought'
export { default as code } from './code'
export { default as collapseContext } from './collapseContext'
export { default as commandPalette } from './commandPalette'
export { default as copyCursor } from './copyCursor'
Expand Down
2 changes: 1 addition & 1 deletion src/components/CommandTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const groups: {
},
{
title: 'Editing thoughts',
commands: ['join', 'splitSentences', 'bold', 'italic', 'strikethrough', 'underline'],
commands: ['join', 'splitSentences', 'bold', 'italic', 'strikethrough', 'underline', 'code'],
},
{
title: 'Oops',
Expand Down
38 changes: 38 additions & 0 deletions src/components/icons/CodeIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import IconType from '../../@types/IconType'
import AnimatedIcon from './AnimatedIcon'
// TODO: Replace with a new icon and animation for code.
import animationData from './animations/13-bold_4.json'

/** Code icon. */
const CodeIcon = ({ fill, size, style = {}, cssRaw, animated, animationComplete }: IconType) => {
return (
<AnimatedIcon {...{ fill, size, style, cssRaw, animated, animationData, animationComplete }}>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
style={{ ...style, width: '100%', height: '100%' }}
>
<rect width='24' height='24' fill='none' />
<path
d='M6,4a.94.94,0,0,1,.94-.94h5.53a4.58,4.58,0,0,1,4.62,4A4.45,4.45,0,0,1,12.66,12H6Z'
fill='none'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
/>
<path
d='M6,12h7.84A4.29,4.29,0,0,1,18,16a4.3,4.3,0,0,1-4,4.89H6.83A.89.89,0,0,1,6,20V12Z'
fill='none'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
/>
</svg>
</AnimatedIcon>
)
}

export default CodeIcon
5 changes: 2 additions & 3 deletions src/device/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,7 @@ export const isThought = (): boolean => {
/** Returns true if the selection is on a thought. */
export const isOnThought = (): boolean => {
let focusNode = window.getSelection()?.focusNode
if (!focusNode) return false
while ((focusNode as HTMLElement)?.tagName !== 'DIV') {
while (focusNode && (focusNode as HTMLElement)?.tagName !== 'DIV') {
if (isEditable(focusNode)) return true
focusNode = focusNode?.parentNode
}
Expand Down Expand Up @@ -408,7 +407,7 @@ const removeEmptyElementsRecursively = (element: HTMLElement, remainText: string

/** Returns the selection html, or null if there is no selection. */
export const html = () => {
const selection = document.getSelection()
const selection = document?.getSelection()
if (!selection || selection.rangeCount === 0) return null
const range = selection?.getRangeAt(0)

Expand Down
15 changes: 4 additions & 11 deletions src/stores/commandStateStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const commandStateStore = reactMinistore<CommandState>({
italic: false,
underline: false,
strikethrough: false,
code: false,
foreColor: undefined,
backColor: undefined,
})
Expand All @@ -22,6 +23,7 @@ export const resetCommandState = () => {
italic: false,
underline: false,
strikethrough: false,
code: false,
foreColor: undefined,
backColor: undefined,
})
Expand All @@ -31,18 +33,9 @@ export const resetCommandState = () => {
export const updateCommandState = () => {
const state = store.getState()
if (!state.cursor) return

// document.queryCommandState is not defined in jsdom, so we need to make sure it exists
const action =
selection.isActive() && document.queryCommandState
? {
bold: document.queryCommandState('bold'),
italic: document.queryCommandState('italic'),
underline: document.queryCommandState('underline'),
strikethrough: document.queryCommandState('strikethrough'),
foreColor: document.queryCommandValue('foreColor'),
backColor: document.queryCommandValue('backColor'),
}
selection.isActive() && selection.isOnThought()
? getCommandState(selection.html() ?? '')
: getCommandState(pathToThought(state, state.cursor).value)
commandStateStore.update(action)
}
Expand Down
43 changes: 41 additions & 2 deletions src/util/__tests__/getCommandState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ it('empty thought', () => {
italic: false,
underline: false,
strikethrough: false,
code: false,
foreColor: undefined,
backColor: undefined,
})
Expand All @@ -17,6 +18,7 @@ it('bold thought', () => {
italic: false,
underline: false,
strikethrough: false,
code: false,
foreColor: undefined,
backColor: undefined,
})
Expand All @@ -28,6 +30,7 @@ it('italic thought', () => {
italic: true,
underline: false,
strikethrough: false,
code: false,
foreColor: undefined,
backColor: undefined,
})
Expand All @@ -39,6 +42,7 @@ it('underline thought', () => {
italic: false,
underline: true,
strikethrough: false,
code: false,
foreColor: undefined,
backColor: undefined,
})
Expand All @@ -50,17 +54,33 @@ it('strikethrough thought', () => {
italic: false,
underline: false,
strikethrough: true,
code: false,
foreColor: undefined,
backColor: undefined,
})
})

it('code thought', () => {
expect(getCommandState('<code>text</code>')).toStrictEqual({
bold: false,
italic: false,
underline: false,
strikethrough: false,
code: true,
foreColor: undefined,
backColor: undefined,
})
})

it('partially styled thought', () => {
expect(getCommandState('<b>Bold</b><i>Italic</i><u>Underline</u><strike>strikethrough</strike>')).toStrictEqual({
expect(
getCommandState('<b>Bold</b><i>Italic</i><u>Underline</u><strike>strikethrough</strike><code>code</code>'),
).toStrictEqual({
bold: false,
italic: false,
underline: false,
strikethrough: false,
code: false,
foreColor: undefined,
backColor: undefined,
})
Expand All @@ -72,6 +92,7 @@ it('text color thought', () => {
italic: false,
underline: false,
strikethrough: false,
code: false,
foreColor: 'rgb(255, 0, 0)',
backColor: undefined,
})
Expand All @@ -83,6 +104,7 @@ it('background color thought', () => {
italic: false,
underline: false,
strikethrough: false,
code: false,
foreColor: undefined,
backColor: 'rgb(0, 0, 255)',
})
Expand All @@ -91,13 +113,30 @@ it('background color thought', () => {
it('fully styled thought', () => {
expect(
getCommandState(
'<b><i><u><strike><font color="rgb(255, 0, 0)"><span style="background-color: rgb(0, 0, 255)">text</span></font></strike></u></i></b>',
'<b><i><u><strike><code><font color="rgb(255, 0, 0)"><span style="background-color: rgb(0, 0, 255)">text</span></font></code></strike></u></i></b>',
),
).toStrictEqual({
bold: true,
italic: true,
underline: true,
strikethrough: true,
code: true,
foreColor: 'rgb(255, 0, 0)',
backColor: 'rgb(0, 0, 255)',
})
})

it('fully styled thought without text content', () => {
expect(
getCommandState(
'<b><i><u><strike><code><font color="rgb(255, 0, 0)"><span style="background-color: rgb(0, 0, 255)"></span></font></code></strike></u></i></b>',
),
).toStrictEqual({
bold: true,
italic: true,
underline: true,
strikethrough: true,
code: true,
foreColor: 'rgb(255, 0, 0)',
backColor: 'rgb(0, 0, 255)',
})
Expand Down
Loading
Loading