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:
parent
aa648cb76b
commit
bd9433d259
|
@ -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}
|
||||
|
|
|
@ -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,8 +103,9 @@ 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'
|
||||
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,
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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 />)}
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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))} />
|
||||
|
|
|
@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -188,7 +188,9 @@ TabsBlock.craft = {
|
|||
related: {
|
||||
toolbar: TabsBlockToolbar,
|
||||
},
|
||||
custom: {},
|
||||
custom: {
|
||||
notTabContent: true,
|
||||
},
|
||||
}
|
||||
|
||||
export {TabsBlock}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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}}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
.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 {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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', () => {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
export function isElementOfStyle(property: string, value: string, elem: Element | null): boolean {
|
||||
if (!elem) return false
|
||||
|
||||
let currentElem: Element | null = elem
|
||||
while (currentElem) {
|
||||
const computedStyle = window.getComputedStyle(currentElem)
|
||||
if (computedStyle[property] === value) {
|
||||
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()
|
||||
}
|
||||
currentElem = currentElem.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function scrollIntoViewWithCallback(
|
||||
|
|
|
@ -24,3 +24,4 @@ export * from './getScrollParent'
|
|||
export * from './getCloneTree'
|
||||
export * from './colorUtils'
|
||||
export * from './getNodeIndex'
|
||||
export * from './deletable'
|
||||
|
|
|
@ -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]
|
||||
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,
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
|
|
Loading…
Reference in New Issue