Tweak some blocks

and prevent Tabs from nesting
and added I18n to SectionMenu

closes RCX-2179
flag=none

test plan:
  - create an empty page
  > you cannot delete the sole blank section
  - add a new section
  > expect the trash can in the toolbar
  - delete all but 1 section
  > you cnnot delete the last section

  - add a Tabs block
  - try to dnd Tabs into the Tabs
  > expect it to be disallowed
  - try to dnd another block into the Tabs
  > expect it to work as expected
  > expect the RCE Block to be present, even if the ai_text_tools
    flag is off
  - Add a columns section
  - add a Text block to a column
  - enter some text, including returns to introduce more
    paragraphs
  - add another text block above or below the first one
  - add some text and <esc> to stop editing
  > expect all the pragraphs to look evenly spaced
  - in any of the text blocks select some text and
    click on bold, italic, underline, and/or strikethru
    in the toolbar
  > expect the corresponding buttons to be highlighted
  > expect the text to get the corresponding styling

  - add a text block
  - type some text with a ?
  > expect the ? to work

Change-Id: I53ab48693873ec0a85979af6813292d8a100aa13
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/354456
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Jacob DeWar <jacob.dewar@instructure.com>
QA-Review: Jacob DeWar <jacob.dewar@instructure.com>
Product-Review: Ed Schiebel <eschiebel@instructure.com>
This commit is contained in:
Ed Schiebel 2024-08-06 14:23:27 -06:00
parent aa648cb76b
commit bd9433d259
28 changed files with 584 additions and 286 deletions

View File

@ -37,7 +37,7 @@ import {FooterSection} from './user/sections/FooterSection'
import {QuizSection} from './user/sections/QuizSection'
import {AnnouncementSection} from './user/sections/AnnouncementSection'
import {BlankSection} from './user/sections/BlankSection'
import {TabsBlock, TabBlock} from './user/blocks/TabsBlock'
import {TabsBlock, TabBlock, TabContent} from './user/blocks/TabsBlock'
import {NoSections} from './user/common'
@ -67,6 +67,7 @@ const blocks = {
BlankSection,
TabsBlock,
TabBlock,
TabContent,
}
export {blocks}

View File

@ -52,6 +52,7 @@ import {View, type ViewProps} from '@instructure/ui-view'
import type {AddSectionPlacement, RenderNodeProps} from './types'
import {SectionBrowser} from './SectionBrowser'
import {notDeletableIfLastChild} from '../../utils'
const findUpNode = (node: Node, query: any): Node | undefined => {
let upnode = node.data.parent ? query.node(node.data.parent).get() : undefined
@ -102,10 +103,11 @@ export const RenderNode: RenderNodeComponent = ({render}: RenderNodeProps) => {
dom: n.dom,
name: n.data.custom.displayName || n.data.displayName,
moveable: node_helpers.isDraggable(),
deletable:
(typeof n.data.custom?.isDeletable === 'function'
? n.data.custom.isDeletable?.(n.id, query)
: true) && node_helpers.isDeletable(),
deletable: n.data.custom?.isSection
? notDeletableIfLastChild(n.id, query)
: (typeof n.data.custom?.isDeletable === 'function'
? n.data.custom.isDeletable?.(n.id, query)
: true) && node_helpers.isDeletable(),
props: n.data.props,
}
})

View File

@ -17,22 +17,18 @@
*/
import React, {useCallback, useEffect, useState} from 'react'
import {useEditor, type Node} from '@craftjs/core'
import {useEditor, useNode, type Node} from '@craftjs/core'
import {Menu} from '@instructure/ui-menu'
import {
// getCloneTree,
scrollIntoViewWithCallback,
getScrollParent,
getSectionLocation,
notDeletableIfLastChild,
type SectionLocation,
} from '../../utils'
import {type AddSectionPlacement} from './types'
import {useScope} from '@canvas/i18n'
function triggerScrollEvent() {
const scrollingContainer = getScrollParent()
const scrollEvent = new Event('scroll')
scrollingContainer.dispatchEvent(scrollEvent)
}
const I18n = useScope('block-editor')
export type SectionMenuProps = {
onEditSection?: (node: Node) => void
@ -56,6 +52,9 @@ const SectionMenu = ({
selected: currentNodeId ? qry.node(currentNodeId) : null,
}
})
const {isDeletable} = useNode((n: Node) => ({
isDeletable: n.data.custom?.isSection && notDeletableIfLastChild(n.id, query),
}))
const [sectionLocation, setSectionLocation] = useState<SectionLocation>(() => {
if (selected?.get()) {
return getSectionLocation(selected.get(), query)
@ -115,7 +114,7 @@ const SectionMenu = ({
actions.move(currentNode.id, parentId, myIndex - 1)
actions.selectNode(currentNode.id)
requestAnimationFrame(() => {
scrollIntoViewWithCallback(currentNode.dom, {block: 'nearest'}, triggerScrollEvent)
currentNode.dom?.scrollIntoView({block: 'nearest'})
})
}
}, [actions, onMoveUp, query, selected])
@ -137,7 +136,7 @@ const SectionMenu = ({
actions.move(currentNode.id, parentId, myIndex + 2)
actions.selectNode(currentNode.id)
requestAnimationFrame(() => {
scrollIntoViewWithCallback(currentNode.dom, {block: 'nearest'}, triggerScrollEvent)
currentNode.dom?.scrollIntoView({block: 'nearest'})
})
}
}, [actions, onMoveDown, query, selected])
@ -161,24 +160,30 @@ const SectionMenu = ({
return (
<Menu show={true} onToggle={() => {}}>
{onEditSection ? <Menu.Item onSelect={handleEditSection}>EditSection</Menu.Item> : null}
{onEditSection ? (
<Menu.Item onSelect={handleEditSection}>{I18n.t('EditSection')}</Menu.Item>
) : null}
{/* <Menu.Item onSelect={handleDuplicateSection}>Duplicate</Menu.Item> */}
<Menu.Item onSelect={handleAddSection.bind(null, 'prepend')}>+ Section Above</Menu.Item>
<Menu.Item onSelect={handleAddSection.bind(null, 'append')}>+ Section Below</Menu.Item>
<Menu.Item onSelect={handleAddSection.bind(null, 'prepend')}>
{I18n.t('+ Section Above')}
</Menu.Item>
<Menu.Item onSelect={handleAddSection.bind(null, 'append')}>
{I18n.t('+ Section Below')}
</Menu.Item>
<Menu.Item
onSelect={handleMoveUp}
disabled={sectionLocation === 'top' || sectionLocation === 'alone'}
>
Move Up
{I18n.t('Move Up')}
</Menu.Item>
<Menu.Item
onSelect={handleMoveDown}
disabled={sectionLocation === 'bottom' || sectionLocation === 'alone'}
>
Move Down
{I18n.t('Move Down')}
</Menu.Item>
<Menu.Item onSelect={handleRemove} disabled={!selected?.isDeletable()}>
Remove
<Menu.Item onSelect={handleRemove} disabled={!isDeletable}>
{I18n.t('Remove')}
</Menu.Item>
</Menu>
)

View File

@ -127,10 +127,8 @@ export const Toolbox = ({open, container, onClose}: ToolboxProps) => {
padding="x-small"
>
{renderBox('Button', ButtonBlockIcon, <ButtonBlock text="Click me" />)}
{renderBox('Text', TextBlockIcon, <TextBlock text="" />)}
{/* @ts-expect-error */}
{window.ENV.RICH_CONTENT_AI_TEXT_TOOLS &&
renderBox('RCE', RCEBlockIcon, <RCEBlock text="" />)}
{renderBox('Text', TextBlockIcon, <TextBlock />)}
{renderBox('RCE', RCEBlockIcon, <RCEBlock text="" />)}
{renderBox('Icon', IconBlockIcon, <IconBlock iconName="apple" />)}
{renderBox('Heading', HeadingBlockIcon, <HeadingBlock />)}
{renderBox('Resource Card', ResourceCardIcon, <ResourceCard />)}

View File

@ -187,7 +187,7 @@ describe('BlockEditor', () => {
it('chnages the rendered dom when changing props via the toolbar', async () => {
renderEditor()
const headingBlock = getHeading()
expect(headingBlock?.parentElement?.tagName).toBe('H2')
expect(headingBlock.firstElementChild?.tagName).toBe('H2')
await user.click(headingBlock)
await user.click(headingBlock)
@ -203,6 +203,6 @@ describe('BlockEditor', () => {
})
await user.click(screen.getByText('Heading 3'))
expect(getHeading().parentElement?.tagName).toBe('H3')
expect(getHeading().firstElementChild?.tagName).toBe('H3')
})
})

View File

@ -36,6 +36,7 @@ const fauxNode = {
parent: 'ROOT',
},
}
let isDeletable = true
jest.mock('@craftjs/core', () => {
const module = jest.requireActual('@craftjs/core')
@ -57,6 +58,9 @@ jest.mock('@craftjs/core', () => {
actions: {},
}
}),
useNode: jest.fn(() => {
return {isDeletable}
}),
}
})
@ -144,8 +148,7 @@ describe('SectionMenu', () => {
it('disables Move Up when at the top', async () => {
getDescendants = () => ['1', '2']
const onMoveUp = jest.fn()
const {getByText} = renderComponent({onMoveUp})
const {getByText} = renderComponent()
const menuitem = getByText('Move Up').closest('[role="menuitem"]')
expect(menuitem).toHaveAttribute('aria-disabled', 'true')
@ -153,10 +156,17 @@ describe('SectionMenu', () => {
it('disables Move Down when at the bottom', async () => {
getDescendants = () => ['2', '1']
const onMoveDown = jest.fn()
const {getByText} = renderComponent({onMoveDown})
const {getByText} = renderComponent()
const menuitem = getByText('Move Down').closest('[role="menuitem"]')
expect(menuitem).toHaveAttribute('aria-disabled', 'true')
})
it('disables Remove when the section is not deletable', async () => {
isDeletable = false
const {getByText} = renderComponent()
const menuitem = getByText('Remove').closest('[role="menuitem"]')
expect(menuitem).toHaveAttribute('aria-disabled', 'true')
})
})

View File

@ -86,19 +86,35 @@ export const HeadingBlock = ({text, level}: HeadingBlockProps) => {
setEditable(true)
}, [])
// TODO: this doesn't work because selectNode selects the section
// due to the our behavior that the first select is the section
// There's something now right with that logic in RenderNode
// Managing selection vs. focus needs work.
const handleFocus = useCallback(() => {
// if (!selected) {
// actions.selectNode(id)
// }
}, [])
if (enabled) {
return (
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
<div ref={el => el && connect(drag(el))} role="textbox" onClick={handleClick}>
<div
ref={el => el && connect(drag(el))}
role="textbox"
onClick={handleClick}
className={clazz}
>
<Heading level={level} color="primary" themeOverride={themeOverride}>
<ContentEditable
innerRef={focusableElem}
data-placeholder={`Heading ${level?.replace('h', '')}`}
className={clazz}
className={!text ? 'empty' : ''}
disabled={!editable}
html={text || ''}
onChange={handleChange}
onKeyDown={handleKey}
onFocus={handleFocus}
tagName="span"
/>
</Heading>

View File

@ -36,7 +36,7 @@ const ImageBlock = ({src, width, height, constraint}: ImageBlockProps) => {
const {
connectors: {connect, drag},
} = useNode()
const clazz = useClassNames(enabled, {empty: !src}, 'image-block')
const clazz = useClassNames(enabled, {empty: !src}, ['block', 'image-block'])
if (!src) {
return <div className={clazz} ref={el => el && connect(drag(el as HTMLDivElement))} />

View File

@ -97,5 +97,13 @@ PageBlock.craft = {
canMoveIn: (incomingNodes: Node[]) => {
return incomingNodes.every((incomingNode: Node) => incomingNode.data.custom.isSection)
},
canMoveOut: (outgoingNodes: Node[], currentNode: Node) => {
return currentNode.data.nodes.length > outgoingNodes.length
},
},
custom: {
isDeletable: (_myId: Node, _query: any) => {
return false
},
},
}

View File

@ -79,15 +79,13 @@ export const RCEBlock = ({id, text, onContentChange}: RCEBlockProps) => {
}}
className={clazz}
role="textbox"
style={{minWidth: '50%'}}
onClick={e => setEditable(true)}
>
<CanvasRce
ref={rceRef}
autosave={false}
defaultContent={text}
editorOptions={{
focus: false,
}}
height={300}
textareaId={`rceblock_text-${id}`}
onFocus={handleRCEFocus}

View File

@ -26,6 +26,7 @@ import {TextBlock} from '../TextBlock'
import {ButtonBlock} from '../ButtonBlock'
import {IconBlock} from '../IconBlock'
import {type ResourceCardProps} from './types'
import {notDeletableIfLastChild} from '../../../../utils'
import {useScope as useI18nScope} from '@canvas/i18n'
@ -93,14 +94,7 @@ ResourceCard.craft = {
linkUrl: '',
},
custom: {
isDeletable: (myId: Node, query: any) => {
const target = query.node(myId).get()
const parent = query.node(target.data.parent).get()
if (parent.rules?.canMoveOut) {
return parent.rules.canMoveOut([target], parent)
}
return true
},
isDeletable: notDeletableIfLastChild,
},
}

View File

@ -49,7 +49,9 @@ describe('ResourceCard', () => {
const heading = container.querySelector('.heading-block')
expect(heading).toBeInTheDocument()
expect(heading?.getAttribute('contenteditable')).toBe('false')
expect(heading?.querySelector('[contenteditable]')?.getAttribute('contenteditable')).toBe(
'false'
)
const desc = container.querySelector('.text-block')
expect(desc).toBeInTheDocument()
@ -61,7 +63,7 @@ describe('ResourceCard', () => {
const heading = container.querySelector('.heading-block') as HTMLElement
await userEvent.click(heading)
expect(heading.getAttribute('contenteditable')).toBe('true')
expect(heading.querySelector('[contenteditable]')?.getAttribute('contenteditable')).toBe('true')
expect(heading.getAttribute('disabled')).toBeNull()
})
// let's assumd if the heading is editable w/in the ResourceCard, the description is editable too

View File

@ -17,34 +17,63 @@
*/
import React, {useState} from 'react'
import {Element, useEditor} from '@craftjs/core'
import {Container} from '../Container'
import {NoSections} from '../../common'
import {Element, useEditor, useNode, type Node} from '@craftjs/core'
import {useClassNames} from '../../../../utils'
import {type TabBlockProps} from './types'
// TabContent is a copy of NoSections with a canMoveIn rule
// preventing nested TabBlocks
export type TabContentProps = {
className?: string
children?: React.ReactNode
}
export const TabContent = ({className = '', children}: TabContentProps) => {
const {enabled} = useEditor(state => ({
enabled: state.options.enabled,
}))
const {
connectors: {connect},
} = useNode()
const clazz = useClassNames(enabled, {empty: !children}, [className])
return (
<div
ref={el => el && connect(el)}
className={clazz}
data-placeholder="Drop a block to add it here"
>
{children}
</div>
)
}
TabContent.craft = {
displayName: 'Tab Content',
rules: {
canMoveIn: (nodes: Node[]) => {
return !nodes.some(node => node.data.custom.isSection || node.data.custom.notTabContent)
},
},
custom: {
noToolbar: true,
notTabContent: true, // cannot be used as tab content
},
}
const TabBlock = ({tabId}: TabBlockProps) => {
const {enabled} = useEditor(state => ({
enabled: state.options.enabled,
}))
const [cid] = useState<string>('tab-block')
const clazz = useClassNames(enabled, {empty: false}, ['block', 'tab-block'])
return (
<Container id={tabId} className={clazz}>
<Element
id={`${cid}_nosection1`}
is={NoSections}
canvas={true}
className="tab-block__inner"
/>
</Container>
)
return <Element id={tabId} is={TabContent} canvas={true} className={clazz} />
}
TabBlock.craft = {
displayName: 'Tab',
custom: {
noToolbar: true,
notTabContent: true,
},
}

View File

@ -188,7 +188,9 @@ TabsBlock.craft = {
related: {
toolbar: TabsBlockToolbar,
},
custom: {},
custom: {
notTabContent: true,
},
}
export {TabsBlock}

View File

@ -0,0 +1,38 @@
/*
* Copyright (C) 2024 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {TabContent} from '../TabBlock'
describe('TabBlock', () => {
describe('TabContent', () => {
it('does not premit nested Tabs', () => {
// this doesn't test the _real_ candidate child components
// but it does test the canMoveIn rule's logic
const canMoveIn = TabContent.craft?.rules?.canMoveIn
const notTabContent = {data: {custom: {notTabContent: true}}}
const aSection = {data: {custom: {isSection: true}}}
const someBlock = {data: {name: 'SomeBlock', custom: {}}}
// @ts-expect-error
expect(canMoveIn([notTabContent, someBlock])).toBe(false)
// @ts-expect-error
expect(canMoveIn([aSection, someBlock])).toBe(false)
// @ts-expect-error
expect(canMoveIn([someBlock, someBlock])).toBe(true)
})
})
})

View File

@ -20,14 +20,13 @@ import React from 'react'
import {render, waitFor} from '@testing-library/react'
import userEvent, {PointerEventsCheckLevel} from '@testing-library/user-event'
import {Editor, Frame} from '@craftjs/core'
import {NoSections} from '../../../common/NoSections'
import {TabsBlock, TabBlock, type TabsBlockProps} from '..'
import {TabsBlock, TabBlock, TabContent, type TabsBlockProps} from '..'
const user = userEvent.setup({pointerEventsCheck: PointerEventsCheckLevel.Never})
const renderBlock = (enabled: boolean, props: Partial<TabsBlockProps> = {}) => {
return render(
<Editor enabled={enabled} resolver={{TabsBlock, TabBlock, NoSections}}>
<Editor enabled={enabled} resolver={{TabsBlock, TabBlock, TabContent}}>
<Frame>
<TabsBlock {...props} />
</Frame>

View File

@ -16,9 +16,9 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {TabBlock} from './TabBlock'
import {TabBlock, TabContent} from './TabBlock'
import {TabsBlock} from './TabsBlock'
import {type TabsBlockProps, type TabBlockProps, type TabsBlockTab, type TabVariant} from './types'
import {type TabsBlockProps, type TabBlockProps, type TabsBlockTab, type TabsVariant} from './types'
const TabsBlockIcon = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.4982 1V1.00164H1.22744C0.554404 1.00164 0 1.57218 0 2.2648L0.00239734 16.1579C0.00239734 16.6181 0.373466 17 0.820688 17H17.1817C17.6289 17 18 16.6181 18 16.1579V4.78947V3.94737V2.26316C18 1.57054 17.4456 1 16.7726 1H4.4982ZM6.46721 1.84211H16.7726C17.0037 1.84211 17.1817 2.02525 17.1817 2.26316V3.94737H6.54393V2.2648C6.54393 2.11611 6.51401 1.97511 6.46721 1.84211ZM1.22744 1.84375H5.31649C5.54767 1.84375 5.72564 2.0269 5.72564 2.2648V4.78947H17.1817V16.1579H0.820688L0.818291 2.2648C0.818291 2.0269 0.996257 1.84375 1.22744 1.84375Z" fill="currentColor"/>
@ -26,10 +26,11 @@ const TabsBlockIcon = `<svg width="18" height="18" viewBox="0 0 18 18" fill="non
export {
TabBlock,
TabContent,
TabsBlock,
TabsBlockIcon,
type TabsBlockProps,
type TabBlockProps,
type TabsBlockTab,
type TabVariant,
type TabsVariant,
}

View File

@ -19,14 +19,7 @@
import React, {useCallback, useEffect, useRef, useState} from 'react'
import ContentEditable from 'react-contenteditable'
import {useEditor, useNode} from '@craftjs/core'
import {
useClassNames,
shouldAddNewNode,
shouldDeleteNode,
addNewNodeAsNextSibling,
deleteNodeAndSelectPrevSibling,
removeLastParagraphTag,
} from '../../../../utils'
import {useClassNames} from '../../../../utils'
import {TextBlockToolbar} from './TextBlockToolbar'
import {type TextBlockProps} from './types'
@ -34,14 +27,15 @@ import {useScope as useI18nScope} from '@canvas/i18n'
const I18n = useI18nScope('block-editor/text-block')
const isAParagraph = (text: string) => /<p>[\s\S]*?<\/p>/s.test(text)
export const TextBlock = ({text = '', fontSize, textAlign, color}: TextBlockProps) => {
const {actions, enabled, query} = useEditor(state => ({
const {enabled} = useEditor(state => ({
enabled: state.options.enabled,
}))
const {
connectors: {connect, drag},
actions: {setProp},
id,
selected,
} = useNode(state => ({
id: state.id,
@ -51,7 +45,6 @@ export const TextBlock = ({text = '', fontSize, textAlign, color}: TextBlockProp
const focusableElem = useRef<HTMLDivElement | null>(null)
const [editable, setEditable] = useState(true)
const lastChar = useRef<string>('')
useEffect(() => {
if (editable && selected) {
@ -63,8 +56,8 @@ export const TextBlock = ({text = '', fontSize, textAlign, color}: TextBlockProp
const handleChange = useCallback(
e => {
let html = e.target.value
if (html === '<p><br></p>' || html === '<div><br></div>') {
html = ''
if (!isAParagraph(html)) {
html = `<p>${html}</p>`
}
setProp((prps: TextBlockProps) => {
@ -74,25 +67,6 @@ export const TextBlock = ({text = '', fontSize, textAlign, color}: TextBlockProp
[setProp]
)
const handleKey = useCallback(
e => {
if (shouldAddNewNode(e, lastChar.current)) {
e.preventDefault()
removeLastParagraphTag(e.currentTarget)
setProp((prps: TextBlockProps) => {
prps.text = e.currentTarget.innerHTML
})
addNewNodeAsNextSibling(<TextBlock text="" />, id, actions, query)
} else if (shouldDeleteNode(e)) {
e.preventDefault()
deleteNodeAndSelectPrevSibling(id, actions, query)
}
lastChar.current = e.key
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[actions, id, lastChar.current, query]
)
if (enabled) {
return (
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
@ -112,7 +86,6 @@ export const TextBlock = ({text = '', fontSize, textAlign, color}: TextBlockProp
disabled={!editable}
html={text}
onChange={handleChange}
onKeyUp={handleKey}
tagName="div"
style={{fontSize, textAlign, color}}
/>

View File

@ -34,7 +34,7 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, {useCallback, useState} from 'react'
import React, {useCallback, useEffect, useState} from 'react'
import {useNode, type Node} from '@craftjs/core'
import {Button, IconButton} from '@instructure/ui-buttons'
import {
@ -49,13 +49,7 @@ import {Flex} from '@instructure/ui-flex'
import {Menu, type MenuItemProps, type MenuItem} from '@instructure/ui-menu'
import {Text} from '@instructure/ui-text'
import {type ViewOwnProps} from '@instructure/ui-view'
import {
isSelectionAllStyled,
isElementBold,
makeSelectionBold,
unstyleSelection,
unboldElement,
} from '../../../../utils'
import {isCaretAtBoldText, isCaretAtStyledText, getCaretPosition} from '../../../../utils'
import {ColorModal} from '../../common/ColorModal'
import {type TextBlockProps} from './types'
@ -63,6 +57,12 @@ import {useScope as useI18nScope} from '@canvas/i18n'
const I18n = useI18nScope('block-editor/text-block')
// NOTE: This component uses document.execCommand which is deprecated, but there
// (1) is still supported by browsers, and
// (2) no reasonable alternative exists for the functionality it provides.
// Should it ever go away, we'll deal with the consequences then (which will mean writing
// a boatload of code to replace a 1-liner.)
const TextBlockToolbar = () => {
const {
actions: {setProp},
@ -73,15 +73,60 @@ const TextBlockToolbar = () => {
props: n.data.props as TextBlockProps,
}))
const [colorModalOpen, setColorModalOpen] = useState(false)
const [editableNode, setEditableNode] = useState(node.dom?.querySelector('[contenteditable]'))
const [caretPos, setCaretPos] = useState(() => {
return editableNode ? getCaretPosition(editableNode) : 0
})
const [isBold, setIsBold] = useState(isCaretAtBoldText())
const [isItalic, setIsItalic] = useState(isCaretAtStyledText('font-style', 'italic'))
const [isUnderline, setIsUnderline] = useState(
isCaretAtStyledText('text-decoration-line', 'underline')
)
const [isStrikeThrough, setIsStrikeThrough] = useState(
isCaretAtStyledText('text-decoration-line', 'line-through')
)
useEffect(() => {
setEditableNode(node.dom?.querySelector('[contenteditable]'))
}, [node.dom])
useEffect(() => {
const handleSelectionChange = () => {
if (editableNode) setCaretPos(getCaretPosition(editableNode))
}
document.addEventListener('selectionchange', handleSelectionChange)
return () => {
document.removeEventListener('selectionchange', handleSelectionChange)
}
}, [editableNode])
useEffect(() => {
setIsBold(isCaretAtBoldText())
setIsItalic(isCaretAtStyledText('font-style', 'italic'))
setIsUnderline(isCaretAtStyledText('text-decoration-line', 'underline'))
setIsStrikeThrough(isCaretAtStyledText('text-decoration-line', 'line-through'))
}, [caretPos])
const handleBold = useCallback(() => {
if (isSelectionAllStyled(isElementBold)) {
unstyleSelection(isElementBold, unboldElement)
} else {
makeSelectionBold()
}
setProp((prps: TextBlockProps) => (prps.text = node.dom?.firstElementChild?.innerHTML))
}, [node.dom, setProp])
document.execCommand('bold')
setIsBold(isCaretAtBoldText())
}, [])
const handleItalic = useCallback(() => {
document.execCommand('italic')
setIsItalic(isCaretAtStyledText('font-style', 'italic'))
}, [])
const handleUnderline = useCallback(() => {
document.execCommand('underline')
setIsUnderline(isCaretAtStyledText('text-decoration-line', 'underline'))
}, [])
const handleStrikeThrough = useCallback(() => {
document.execCommand('strikeThrough')
setIsStrikeThrough(isCaretAtStyledText('text-decoration-line', 'line-through'))
}, [])
const handleFontSizeChange = useCallback(
(
@ -116,21 +161,32 @@ const TextBlockToolbar = () => {
<IconButton
screenReaderLabel="Bold"
withBackground={false}
withBorder={false}
withBorder={isBold}
onClick={handleBold}
>
<IconBoldLine size="x-small" />
</IconButton>
<IconButton screenReaderLabel={I18n.t('Italic')} withBackground={false} withBorder={false}>
<IconButton
screenReaderLabel={I18n.t('Italic')}
withBackground={false}
withBorder={isItalic}
onClick={handleItalic}
>
<IconItalicLine size="x-small" />
</IconButton>
<IconButton screenReaderLabel={I18n.t('Underline')} withBackground={false} withBorder={false}>
<IconButton
screenReaderLabel={I18n.t('Underline')}
withBackground={false}
withBorder={isUnderline}
onClick={handleUnderline}
>
<IconUnderlineLine size="x-small" />
</IconButton>
<IconButton
screenReaderLabel={I18n.t('Strikethrough')}
withBackground={false}
withBorder={false}
withBorder={isStrikeThrough}
onClick={handleStrikeThrough}
>
<IconStrikethroughLine size="x-small" />
</IconButton>

View File

@ -69,25 +69,6 @@
.font7 {font-family: 'trebuchet ms', geneva;}
.font8 {font-family: verdana, geneva;}
.block {
position: relative;
line-height: 1.2rem;
min-height: 1.2rem;
&.empty {
border: 1px dotted #ababab;
}
&.rce-text-block {
min-width: 50%;
}
}
.page-block {
background: transparent;
padding: 16px;
min-height: 10rem;
margin: 2px;
}
.colorPalette {
box-sizing: border-box;
height: 20px;
@ -207,9 +188,24 @@
min-width: 2.5rem;
}
.paragraph-block {
min-height: 1.5rem;
line-height: 1.5rem;
.block {
position: relative;
min-height: 2rem;
min-width: 2rem;
&.page-block {
background: transparent;
padding: 16px;
min-height: 10rem;
margin: 2px;
}
&.paragraph-block {
min-height: 1.5rem;
line-height: 1.5rem;
}
&.image-block {
min-height: 24px;
min-width: 24px;
}
}
.resource-card {
@ -347,9 +343,16 @@
.text-block {
max-width: 100%;
text-wrap: balance;
margin: 12px 0; /* matches Canvas' global p margin */
&.enabled {
text-wrap: stable;
}
p:first-child {
margin-top: 0;
}
p:last-child {
margin-bottom: 0;
}
}
.quiz-section {

View File

@ -0,0 +1,60 @@
/*
* Copyright (C) 2024 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {notDeletableIfLastChild} from '../deletable'
describe('notDeletableIfLastChild', () => {
it('returns false if the node is the last child of its parent', () => {
const query = {
node: jest.fn().mockReturnValue({
get: jest.fn().mockReturnValue({
data: {
parent: 'parent',
},
}),
descendants: jest.fn().mockReturnValue([1]),
}),
}
expect(notDeletableIfLastChild('nodeId', query)).toBe(false)
})
it('returns true if the node is not the last child of its parent', () => {
const query = {
node: jest.fn().mockReturnValue({
get: jest.fn().mockReturnValue({
data: {
parent: 'parent',
},
}),
descendants: jest.fn().mockReturnValue([1, 2]),
}),
}
expect(notDeletableIfLastChild('nodeId', query)).toBe(true)
})
it('returns false if the node has no parent', () => {
const query = {
node: jest.fn().mockReturnValue({
get: jest.fn().mockReturnValue({
data: {},
}),
}),
}
expect(notDeletableIfLastChild('nodeId', query)).toBe(false)
})
})

View File

@ -0,0 +1,108 @@
/*
* Copyright (C) 2024 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {setCaretToOffset} from '../kb'
import {isCaretAtBoldText, isElementBold, isCaretAtStyledText, isElementOfStyle} from '../dom'
describe('dom utilities', () => {
describe('isElementBold', () => {
it('should return true if the element is bold', () => {
const div = document.createElement('div')
div.innerHTML = '<b>bold</b>'
const b = div.querySelector('b') as HTMLElement
expect(isElementBold(b)).toBe(true)
})
it('should return true if the element is bold with font-weight', () => {
const div = document.createElement('div')
div.innerHTML = '<p style="font-weight: bold">bold</p>'
const p = div.querySelector('p') as HTMLElement
expect(isElementBold(p)).toBe(true)
})
it('should return true if the element is bold with font-weight number', () => {
const div = document.createElement('div')
div.innerHTML = '<p style="font-weight: 700">bold</p>'
const p = div.querySelector('p') as HTMLElement
expect(isElementBold(p)).toBe(true)
})
it('should return false if the element is not bold', () => {
const div = document.createElement('div')
div.innerHTML = '<p>not bold</p>'
const p = div.querySelector('p') as HTMLElement
expect(isElementBold(p)).toBe(false)
})
})
describe('isCaretAtBoldText', () => {
it('should return true if the caret is at bold text', () => {
const div = document.createElement('div')
div.innerHTML = '<p>this is <b>bold</b> text'
document.body.appendChild(div)
const b = div.querySelector('b') as HTMLElement
setCaretToOffset(b, 2)
expect(isCaretAtBoldText()).toBe(true)
})
it('should return false if the caret is not at bold text', () => {
const div = document.createElement('div')
div.innerHTML = '<p>this is <b>bold</b> text'
document.body.appendChild(div)
const p = div.querySelector('p') as HTMLElement
setCaretToOffset(p, 2)
expect(isCaretAtBoldText()).toBe(false)
})
})
describe('isElementOfStyle', () => {
it('should return true if the element has the style', () => {
const div = document.createElement('div')
div.innerHTML = '<p style="color: red">red</p>'
const p = div.querySelector('p') as HTMLElement
expect(isElementOfStyle('color', 'red', p)).toBe(true)
})
it('should return false if the element does not have the style', () => {
const div = document.createElement('div')
div.innerHTML = '<p>not red</p>'
const p = div.querySelector('p') as HTMLElement
expect(isElementOfStyle('color', 'red', p)).toBe(false)
})
})
describe('isCaretAtStyledText', () => {
it('should return true if the caret is at text with the style', () => {
const div = document.createElement('div')
div.innerHTML = '<p style="color: red">red</p>'
document.body.appendChild(div)
const p = div.querySelector('p') as HTMLElement
setCaretToOffset(p, 2)
expect(isCaretAtStyledText('color', 'red')).toBe(true)
})
it('should return false if the caret is not at text with the style', () => {
const div = document.createElement('div')
div.innerHTML = '<p style="color: red">red</p>'
document.body.appendChild(div)
const p = div.querySelector('p') as HTMLElement
setCaretToOffset(p, 2)
expect(isCaretAtStyledText('color', 'blue')).toBe(false)
})
})
})

View File

@ -16,14 +16,15 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {KeyboardEvent} from 'react'
import {type KeyboardEvent} from 'react'
import {
isAnyModifierKeyPressed,
getCaretPosition,
isCaretAtEnd,
setCaretToEnd,
setCaretToOffset,
shouldAddNewNode,
removeLastParagraphTag,
removeTrailingEmptyParagraphTags,
shouldDeleteNode,
} from '../kb'
@ -125,6 +126,26 @@ describe('keyboard utilities', () => {
})
})
describe('setCaretToOffset', () => {
it('should set the caret to the offset', () => {
const div = document.createElement('div')
div.innerHTML = '<p>hello world</p>'
document.body.appendChild(div)
const p = div.querySelector('p') as HTMLElement
const textNode = p.firstChild as Text
const range = document.createRange()
range.setStart(textNode, 0)
range.setEnd(textNode, 0)
const sel = window.getSelection()
sel?.removeAllRanges()
sel?.addRange(range)
expect(getCaretPosition(div)).toBe(0)
setCaretToOffset(p, 5)
expect(getCaretPosition(div)).toBe(5)
})
})
describe('shouldAddNewNode', () => {
it('should return true if this is the 2nd Enter key press', () => {
const div = document.createElement('div')
@ -185,22 +206,38 @@ describe('keyboard utilities', () => {
})
})
describe('removeLastParagraphTag', () => {
it('should remove the last paragraph tag', () => {
describe('removeTrailingEmptyParagraphTags', () => {
it('should remove the last paragraph tag if it is empty', () => {
const div = document.createElement('div')
div.innerHTML = '<p id="p1">hello</p><p id="p2"></p>'
document.body.appendChild(div)
removeTrailingEmptyParagraphTags(div)
expect(div.innerHTML).toBe('<p id="p1">hello</p>')
})
it('should not remove the last paragraph tag if it has content', () => {
const div = document.createElement('div')
div.innerHTML = '<p id="p1">hello</p><p id="p2">world</p>'
document.body.appendChild(div)
removeLastParagraphTag(div)
expect(div.innerHTML).toBe('<p id="p1">hello</p>')
removeTrailingEmptyParagraphTags(div)
expect(div.innerHTML).toBe('<p id="p1">hello</p><p id="p2">world</p>')
})
it('should do nothing if there are no paragraph tags', () => {
const div = document.createElement('div')
div.innerHTML = 'hello world'
document.body.appendChild(div)
removeLastParagraphTag(div)
removeTrailingEmptyParagraphTags(div)
expect(div.innerHTML).toBe('hello world')
})
it('should do nothing if the last element is not a paragraph tag', () => {
const div = document.createElement('div')
div.innerHTML = '<p id="p1">hello</p><div id="p2"></div>'
document.body.appendChild(div)
removeTrailingEmptyParagraphTags(div)
expect(div.innerHTML).toBe('<p id="p1">hello</p><div id="p2"></div>')
})
})
describe('shouldDeleteNode', () => {

View File

@ -0,0 +1,31 @@
/*
* Copyright (C) 2024 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
// Used as part of the isDeletable logic.
// Return false if the Node identified my nodeId is the last child
// of its parent.
// This is true of all sections (so this is called in RenderNode to
// save you from having to add it to each section) and some blocks
export const notDeletableIfLastChild = (nodeId: string, query: any) => {
const target = query.node(nodeId).get()
if (target.data.parent) {
const siblings = query.node(target.data.parent).descendants()
return siblings.length > 1
}
return false
}

View File

@ -33,25 +33,22 @@ const trayHeight: string = ''
// return trayHeight
// }
export type isStyledFunction = (node: Element) => boolean
export type unstyleFunction = (node: Element) => void
export type styleSelectionFunction = () => void
export function makeSelectionBold(): void {
unstyleSelection(isElementBold, unboldElement)
// bold is unique because font-weight can be 'bold' or a number
export function isCaretAtBoldText(): boolean {
const selection = window.getSelection()
if (selection?.rangeCount) {
const range = selection.getRangeAt(0)
const boldNode = document.createElement('span')
boldNode.style.fontWeight = 'bold'
boldNode.appendChild(range.extractContents())
range.insertNode(boldNode)
selection.removeAllRanges()
selection.addRange(range)
const caretNode = range.startContainer
const caretElement =
caretNode.nodeType === Node.TEXT_NODE ? caretNode.parentElement : (caretNode as Element)
return isElementBold(caretElement)
}
return false
}
export function isElementBold(elem: Element): boolean {
export function isElementBold(elem: Element | null): boolean {
if (!elem) return false
const computedStyle = window.getComputedStyle(elem)
const isBold: boolean =
computedStyle.fontWeight === 'bold' ||
@ -61,114 +58,30 @@ export function isElementBold(elem: Element): boolean {
return isBold
}
export function unboldElement(elem: Element): void {
if (
elem.tagName === 'B' ||
elem.tagName === 'STRONG' ||
elem.getAttribute('style')?.split(':').length === 2 // font-weight is the only style attribute
) {
// Replace the <b>, <strong>, or bold-styled tag with its contents
const fragment = document.createDocumentFragment()
while (elem.firstChild) {
fragment.appendChild(elem.firstChild)
}
elem.parentNode?.replaceChild(fragment, elem)
} else {
// Remove bold styling from the element
;(elem as HTMLElement).style.fontWeight = 'normal'
export function isCaretAtStyledText(property: string, value: string): boolean {
const selection = window.getSelection()
if (selection?.rangeCount) {
const range = selection.getRangeAt(0)
const caretNode = range.startContainer
const caretElement =
caretNode.nodeType === Node.TEXT_NODE ? caretNode.parentElement : (caretNode as Element)
return isElementOfStyle(property, value, caretElement)
}
return false
}
export function isSelectionAllStyled(styleChecker: isStyledFunction): boolean {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
return false
}
export function isElementOfStyle(property: string, value: string, elem: Element | null): boolean {
if (!elem) return false
// Iterate over all ranges in the selection
for (let i = 0; i < selection.rangeCount; i++) {
const range: Range = selection.getRangeAt(i)
const commonAncestor: Node = range.commonAncestorContainer
// Create a tree walker to traverse all nodes within the range
const walker: TreeWalker = document.createTreeWalker(commonAncestor, NodeFilter.SHOW_TEXT, {
acceptNode: (node: Node): number => {
// Only consider nodes that are fully or partially within the range
return range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT
},
})
let node: Node | null
// Special case handling for direct TextNode selection
if (commonAncestor.nodeType === Node.TEXT_NODE) {
node = commonAncestor
} else {
node = walker.nextNode()
}
while (node) {
const parentElement = node.parentElement
if (parentElement) {
const isBold = styleChecker(parentElement)
if (!isBold) {
return false
}
}
node = walker.nextNode()
}
}
return true
}
export function unstyleSelection(
isElemStyled: isStyledFunction,
unStyleElement: unstyleFunction
): void {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
return
}
// Iterate over all ranges in the selection
for (let i = 0; i < selection.rangeCount; i++) {
const range: Range = selection.getRangeAt(i)
const commonAncestor: Node = range.commonAncestorContainer
// Create a tree walker to traverse all nodes within the range
const walker: TreeWalker = document.createTreeWalker(commonAncestor, NodeFilter.SHOW_TEXT, {
acceptNode: (node: Node): number => {
// Only consider nodes that are fully or partially within the range
return range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT
},
})
let node: Node | null
// Special case handling for direct TextNode selection
if (commonAncestor.nodeType === Node.TEXT_NODE) {
node = commonAncestor
} else {
node = walker.nextNode()
}
while (node) {
const parentElement = node.parentElement
if (parentElement) {
// Check if the parent element of the text node is bold
const isBold = isElemStyled(parentElement)
if (isBold) {
unStyleElement(parentElement)
}
}
node = walker.nextNode()
let currentElem: Element | null = elem
while (currentElem) {
const computedStyle = window.getComputedStyle(currentElem)
if (computedStyle[property] === value) {
return true
}
currentElem = currentElem.parentElement
}
return false
}
export function scrollIntoViewWithCallback(

View File

@ -24,3 +24,4 @@ export * from './getScrollParent'
export * from './getCloneTree'
export * from './colorUtils'
export * from './getNodeIndex'
export * from './deletable'

View File

@ -21,7 +21,7 @@ import React from 'react'
const isAnyModifierKeyPressed = (event: React.KeyboardEvent) =>
event.ctrlKey || event.metaKey || event.shiftKey || event.altKey
const getCaretPosition = (editableElement: HTMLElement): number => {
const getCaretPosition = (editableElement: Element): number => {
let caretOffset = 0
const doc = editableElement.ownerDocument
const win = doc.defaultView
@ -62,20 +62,21 @@ const setCaretToEnd = (editableElement: HTMLElement) => {
}
}
// const setCaretToOffset = (editableElement: HTMLElement, offset: number) => {
// const range = document.createRange()
// const sel = window.getSelection()
// if (sel) {
// const textNode = editableElement.querySelector('p')?.firstChild
// if (textNode) {
// range.setStart(textNode, offset)
// range.collapse(true)
const setCaretToOffset = (element: HTMLElement, offset: number) => {
const range = document.createRange()
const sel = window.getSelection()
if (sel) {
const textNode = element.firstChild
// sel.removeAllRanges()
// sel.addRange(range)
// }
// }
// }
if (textNode) {
range.setStart(textNode, offset)
range.collapse(true)
sel.removeAllRanges()
sel.addRange(range)
}
}
}
const addNewNodeAsNextSibling = (
newNode: React.ReactElement,
@ -96,17 +97,21 @@ const shouldAddNewNode = (e: React.KeyboardEvent, lastChar: string) => {
if (!e.currentTarget.textContent) return false
return (
e.key === 'Enter' &&
lastChar === 'Enter' &&
!isAnyModifierKeyPressed(e) &&
isCaretAtEnd(e.currentTarget as HTMLElement) &&
lastChar === 'Enter'
isCaretAtEnd(e.currentTarget as HTMLElement)
)
}
const removeLastParagraphTag = (elem: HTMLElement) => {
const removeTrailingEmptyParagraphTags = (elem: HTMLElement) => {
const paras = elem.querySelectorAll('p')
if (paras.length > 0) {
const lastPara = paras[paras.length - 1]
lastPara.remove()
let lastPara = elem.lastElementChild
// contenteditable puts a <br> in empty paragraphs
while (lastPara && lastPara.tagName === 'P' && lastPara.textContent === '') {
lastPara.remove()
lastPara = elem.lastElementChild
}
}
}
@ -129,9 +134,10 @@ export {
getCaretPosition,
isCaretAtEnd,
setCaretToEnd,
setCaretToOffset,
shouldAddNewNode,
shouldDeleteNode,
addNewNodeAsNextSibling,
deleteNodeAndSelectPrevSibling,
removeLastParagraphTag,
removeTrailingEmptyParagraphTags,
}

View File

@ -54,7 +54,14 @@ KeyboardNavDialog.prototype.bindOpenKeys = function () {
(function (_this) {
return function (e) {
const isQuestionMark = e.keyCode === 191 && e.shiftKey
if (isQuestionMark && !$(e.target).is(':input') && !ENV.disable_keyboard_shortcuts) {
if (
isQuestionMark &&
!(
$(e.target).is(':input') ||
(e.target.getAttribute && e.target.getAttribute('contenteditable') === 'true')
) &&
!ENV.disable_keyboard_shortcuts
) {
e.preventDefault()
if (_this.$el.is(':visible')) {
_this.$el.dialog('close')