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 {QuizSection} from './user/sections/QuizSection'
|
||||||
import {AnnouncementSection} from './user/sections/AnnouncementSection'
|
import {AnnouncementSection} from './user/sections/AnnouncementSection'
|
||||||
import {BlankSection} from './user/sections/BlankSection'
|
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'
|
import {NoSections} from './user/common'
|
||||||
|
|
||||||
|
@ -67,6 +67,7 @@ const blocks = {
|
||||||
BlankSection,
|
BlankSection,
|
||||||
TabsBlock,
|
TabsBlock,
|
||||||
TabBlock,
|
TabBlock,
|
||||||
|
TabContent,
|
||||||
}
|
}
|
||||||
|
|
||||||
export {blocks}
|
export {blocks}
|
||||||
|
|
|
@ -52,6 +52,7 @@ import {View, type ViewProps} from '@instructure/ui-view'
|
||||||
|
|
||||||
import type {AddSectionPlacement, RenderNodeProps} from './types'
|
import type {AddSectionPlacement, RenderNodeProps} from './types'
|
||||||
import {SectionBrowser} from './SectionBrowser'
|
import {SectionBrowser} from './SectionBrowser'
|
||||||
|
import {notDeletableIfLastChild} from '../../utils'
|
||||||
|
|
||||||
const findUpNode = (node: Node, query: any): Node | undefined => {
|
const findUpNode = (node: Node, query: any): Node | undefined => {
|
||||||
let upnode = node.data.parent ? query.node(node.data.parent).get() : 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,
|
dom: n.dom,
|
||||||
name: n.data.custom.displayName || n.data.displayName,
|
name: n.data.custom.displayName || n.data.displayName,
|
||||||
moveable: node_helpers.isDraggable(),
|
moveable: node_helpers.isDraggable(),
|
||||||
deletable:
|
deletable: n.data.custom?.isSection
|
||||||
(typeof n.data.custom?.isDeletable === 'function'
|
? notDeletableIfLastChild(n.id, query)
|
||||||
? n.data.custom.isDeletable?.(n.id, query)
|
: (typeof n.data.custom?.isDeletable === 'function'
|
||||||
: true) && node_helpers.isDeletable(),
|
? n.data.custom.isDeletable?.(n.id, query)
|
||||||
|
: true) && node_helpers.isDeletable(),
|
||||||
props: n.data.props,
|
props: n.data.props,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -17,22 +17,18 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {useCallback, useEffect, useState} from 'react'
|
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 {Menu} from '@instructure/ui-menu'
|
||||||
import {
|
import {
|
||||||
// getCloneTree,
|
// getCloneTree,
|
||||||
scrollIntoViewWithCallback,
|
|
||||||
getScrollParent,
|
|
||||||
getSectionLocation,
|
getSectionLocation,
|
||||||
|
notDeletableIfLastChild,
|
||||||
type SectionLocation,
|
type SectionLocation,
|
||||||
} from '../../utils'
|
} from '../../utils'
|
||||||
import {type AddSectionPlacement} from './types'
|
import {type AddSectionPlacement} from './types'
|
||||||
|
import {useScope} from '@canvas/i18n'
|
||||||
|
|
||||||
function triggerScrollEvent() {
|
const I18n = useScope('block-editor')
|
||||||
const scrollingContainer = getScrollParent()
|
|
||||||
const scrollEvent = new Event('scroll')
|
|
||||||
scrollingContainer.dispatchEvent(scrollEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SectionMenuProps = {
|
export type SectionMenuProps = {
|
||||||
onEditSection?: (node: Node) => void
|
onEditSection?: (node: Node) => void
|
||||||
|
@ -56,6 +52,9 @@ const SectionMenu = ({
|
||||||
selected: currentNodeId ? qry.node(currentNodeId) : null,
|
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>(() => {
|
const [sectionLocation, setSectionLocation] = useState<SectionLocation>(() => {
|
||||||
if (selected?.get()) {
|
if (selected?.get()) {
|
||||||
return getSectionLocation(selected.get(), query)
|
return getSectionLocation(selected.get(), query)
|
||||||
|
@ -115,7 +114,7 @@ const SectionMenu = ({
|
||||||
actions.move(currentNode.id, parentId, myIndex - 1)
|
actions.move(currentNode.id, parentId, myIndex - 1)
|
||||||
actions.selectNode(currentNode.id)
|
actions.selectNode(currentNode.id)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
scrollIntoViewWithCallback(currentNode.dom, {block: 'nearest'}, triggerScrollEvent)
|
currentNode.dom?.scrollIntoView({block: 'nearest'})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [actions, onMoveUp, query, selected])
|
}, [actions, onMoveUp, query, selected])
|
||||||
|
@ -137,7 +136,7 @@ const SectionMenu = ({
|
||||||
actions.move(currentNode.id, parentId, myIndex + 2)
|
actions.move(currentNode.id, parentId, myIndex + 2)
|
||||||
actions.selectNode(currentNode.id)
|
actions.selectNode(currentNode.id)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
scrollIntoViewWithCallback(currentNode.dom, {block: 'nearest'}, triggerScrollEvent)
|
currentNode.dom?.scrollIntoView({block: 'nearest'})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [actions, onMoveDown, query, selected])
|
}, [actions, onMoveDown, query, selected])
|
||||||
|
@ -161,24 +160,30 @@ const SectionMenu = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu show={true} onToggle={() => {}}>
|
<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={handleDuplicateSection}>Duplicate</Menu.Item> */}
|
||||||
<Menu.Item onSelect={handleAddSection.bind(null, 'prepend')}>+ Section Above</Menu.Item>
|
<Menu.Item onSelect={handleAddSection.bind(null, 'prepend')}>
|
||||||
<Menu.Item onSelect={handleAddSection.bind(null, 'append')}>+ Section Below</Menu.Item>
|
{I18n.t('+ Section Above')}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item onSelect={handleAddSection.bind(null, 'append')}>
|
||||||
|
{I18n.t('+ Section Below')}
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onSelect={handleMoveUp}
|
onSelect={handleMoveUp}
|
||||||
disabled={sectionLocation === 'top' || sectionLocation === 'alone'}
|
disabled={sectionLocation === 'top' || sectionLocation === 'alone'}
|
||||||
>
|
>
|
||||||
Move Up
|
{I18n.t('Move Up')}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onSelect={handleMoveDown}
|
onSelect={handleMoveDown}
|
||||||
disabled={sectionLocation === 'bottom' || sectionLocation === 'alone'}
|
disabled={sectionLocation === 'bottom' || sectionLocation === 'alone'}
|
||||||
>
|
>
|
||||||
Move Down
|
{I18n.t('Move Down')}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item onSelect={handleRemove} disabled={!selected?.isDeletable()}>
|
<Menu.Item onSelect={handleRemove} disabled={!isDeletable}>
|
||||||
Remove
|
{I18n.t('Remove')}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu>
|
</Menu>
|
||||||
)
|
)
|
||||||
|
|
|
@ -127,10 +127,8 @@ export const Toolbox = ({open, container, onClose}: ToolboxProps) => {
|
||||||
padding="x-small"
|
padding="x-small"
|
||||||
>
|
>
|
||||||
{renderBox('Button', ButtonBlockIcon, <ButtonBlock text="Click me" />)}
|
{renderBox('Button', ButtonBlockIcon, <ButtonBlock text="Click me" />)}
|
||||||
{renderBox('Text', TextBlockIcon, <TextBlock text="" />)}
|
{renderBox('Text', TextBlockIcon, <TextBlock />)}
|
||||||
{/* @ts-expect-error */}
|
{renderBox('RCE', RCEBlockIcon, <RCEBlock text="" />)}
|
||||||
{window.ENV.RICH_CONTENT_AI_TEXT_TOOLS &&
|
|
||||||
renderBox('RCE', RCEBlockIcon, <RCEBlock text="" />)}
|
|
||||||
{renderBox('Icon', IconBlockIcon, <IconBlock iconName="apple" />)}
|
{renderBox('Icon', IconBlockIcon, <IconBlock iconName="apple" />)}
|
||||||
{renderBox('Heading', HeadingBlockIcon, <HeadingBlock />)}
|
{renderBox('Heading', HeadingBlockIcon, <HeadingBlock />)}
|
||||||
{renderBox('Resource Card', ResourceCardIcon, <ResourceCard />)}
|
{renderBox('Resource Card', ResourceCardIcon, <ResourceCard />)}
|
||||||
|
|
|
@ -187,7 +187,7 @@ describe('BlockEditor', () => {
|
||||||
it('chnages the rendered dom when changing props via the toolbar', async () => {
|
it('chnages the rendered dom when changing props via the toolbar', async () => {
|
||||||
renderEditor()
|
renderEditor()
|
||||||
const headingBlock = getHeading()
|
const headingBlock = getHeading()
|
||||||
expect(headingBlock?.parentElement?.tagName).toBe('H2')
|
expect(headingBlock.firstElementChild?.tagName).toBe('H2')
|
||||||
|
|
||||||
await user.click(headingBlock)
|
await user.click(headingBlock)
|
||||||
await user.click(headingBlock)
|
await user.click(headingBlock)
|
||||||
|
@ -203,6 +203,6 @@ describe('BlockEditor', () => {
|
||||||
})
|
})
|
||||||
await user.click(screen.getByText('Heading 3'))
|
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',
|
parent: 'ROOT',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
let isDeletable = true
|
||||||
|
|
||||||
jest.mock('@craftjs/core', () => {
|
jest.mock('@craftjs/core', () => {
|
||||||
const module = jest.requireActual('@craftjs/core')
|
const module = jest.requireActual('@craftjs/core')
|
||||||
|
@ -57,6 +58,9 @@ jest.mock('@craftjs/core', () => {
|
||||||
actions: {},
|
actions: {},
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
useNode: jest.fn(() => {
|
||||||
|
return {isDeletable}
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -144,8 +148,7 @@ describe('SectionMenu', () => {
|
||||||
|
|
||||||
it('disables Move Up when at the top', async () => {
|
it('disables Move Up when at the top', async () => {
|
||||||
getDescendants = () => ['1', '2']
|
getDescendants = () => ['1', '2']
|
||||||
const onMoveUp = jest.fn()
|
const {getByText} = renderComponent()
|
||||||
const {getByText} = renderComponent({onMoveUp})
|
|
||||||
|
|
||||||
const menuitem = getByText('Move Up').closest('[role="menuitem"]')
|
const menuitem = getByText('Move Up').closest('[role="menuitem"]')
|
||||||
expect(menuitem).toHaveAttribute('aria-disabled', 'true')
|
expect(menuitem).toHaveAttribute('aria-disabled', 'true')
|
||||||
|
@ -153,10 +156,17 @@ describe('SectionMenu', () => {
|
||||||
|
|
||||||
it('disables Move Down when at the bottom', async () => {
|
it('disables Move Down when at the bottom', async () => {
|
||||||
getDescendants = () => ['2', '1']
|
getDescendants = () => ['2', '1']
|
||||||
const onMoveDown = jest.fn()
|
const {getByText} = renderComponent()
|
||||||
const {getByText} = renderComponent({onMoveDown})
|
|
||||||
|
|
||||||
const menuitem = getByText('Move Down').closest('[role="menuitem"]')
|
const menuitem = getByText('Move Down').closest('[role="menuitem"]')
|
||||||
expect(menuitem).toHaveAttribute('aria-disabled', 'true')
|
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)
|
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) {
|
if (enabled) {
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
|
// 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}>
|
<Heading level={level} color="primary" themeOverride={themeOverride}>
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
innerRef={focusableElem}
|
innerRef={focusableElem}
|
||||||
data-placeholder={`Heading ${level?.replace('h', '')}`}
|
data-placeholder={`Heading ${level?.replace('h', '')}`}
|
||||||
className={clazz}
|
className={!text ? 'empty' : ''}
|
||||||
disabled={!editable}
|
disabled={!editable}
|
||||||
html={text || ''}
|
html={text || ''}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKey}
|
onKeyDown={handleKey}
|
||||||
|
onFocus={handleFocus}
|
||||||
tagName="span"
|
tagName="span"
|
||||||
/>
|
/>
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
|
@ -36,7 +36,7 @@ const ImageBlock = ({src, width, height, constraint}: ImageBlockProps) => {
|
||||||
const {
|
const {
|
||||||
connectors: {connect, drag},
|
connectors: {connect, drag},
|
||||||
} = useNode()
|
} = useNode()
|
||||||
const clazz = useClassNames(enabled, {empty: !src}, 'image-block')
|
const clazz = useClassNames(enabled, {empty: !src}, ['block', 'image-block'])
|
||||||
|
|
||||||
if (!src) {
|
if (!src) {
|
||||||
return <div className={clazz} ref={el => el && connect(drag(el as HTMLDivElement))} />
|
return <div className={clazz} ref={el => el && connect(drag(el as HTMLDivElement))} />
|
||||||
|
|
|
@ -97,5 +97,13 @@ PageBlock.craft = {
|
||||||
canMoveIn: (incomingNodes: Node[]) => {
|
canMoveIn: (incomingNodes: Node[]) => {
|
||||||
return incomingNodes.every((incomingNode: Node) => incomingNode.data.custom.isSection)
|
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}
|
className={clazz}
|
||||||
role="textbox"
|
role="textbox"
|
||||||
|
style={{minWidth: '50%'}}
|
||||||
onClick={e => setEditable(true)}
|
onClick={e => setEditable(true)}
|
||||||
>
|
>
|
||||||
<CanvasRce
|
<CanvasRce
|
||||||
ref={rceRef}
|
ref={rceRef}
|
||||||
autosave={false}
|
autosave={false}
|
||||||
defaultContent={text}
|
defaultContent={text}
|
||||||
editorOptions={{
|
|
||||||
focus: false,
|
|
||||||
}}
|
|
||||||
height={300}
|
height={300}
|
||||||
textareaId={`rceblock_text-${id}`}
|
textareaId={`rceblock_text-${id}`}
|
||||||
onFocus={handleRCEFocus}
|
onFocus={handleRCEFocus}
|
||||||
|
|
|
@ -26,6 +26,7 @@ import {TextBlock} from '../TextBlock'
|
||||||
import {ButtonBlock} from '../ButtonBlock'
|
import {ButtonBlock} from '../ButtonBlock'
|
||||||
import {IconBlock} from '../IconBlock'
|
import {IconBlock} from '../IconBlock'
|
||||||
import {type ResourceCardProps} from './types'
|
import {type ResourceCardProps} from './types'
|
||||||
|
import {notDeletableIfLastChild} from '../../../../utils'
|
||||||
|
|
||||||
import {useScope as useI18nScope} from '@canvas/i18n'
|
import {useScope as useI18nScope} from '@canvas/i18n'
|
||||||
|
|
||||||
|
@ -93,14 +94,7 @@ ResourceCard.craft = {
|
||||||
linkUrl: '',
|
linkUrl: '',
|
||||||
},
|
},
|
||||||
custom: {
|
custom: {
|
||||||
isDeletable: (myId: Node, query: any) => {
|
isDeletable: notDeletableIfLastChild,
|
||||||
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
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -49,7 +49,9 @@ describe('ResourceCard', () => {
|
||||||
|
|
||||||
const heading = container.querySelector('.heading-block')
|
const heading = container.querySelector('.heading-block')
|
||||||
expect(heading).toBeInTheDocument()
|
expect(heading).toBeInTheDocument()
|
||||||
expect(heading?.getAttribute('contenteditable')).toBe('false')
|
expect(heading?.querySelector('[contenteditable]')?.getAttribute('contenteditable')).toBe(
|
||||||
|
'false'
|
||||||
|
)
|
||||||
|
|
||||||
const desc = container.querySelector('.text-block')
|
const desc = container.querySelector('.text-block')
|
||||||
expect(desc).toBeInTheDocument()
|
expect(desc).toBeInTheDocument()
|
||||||
|
@ -61,7 +63,7 @@ describe('ResourceCard', () => {
|
||||||
const heading = container.querySelector('.heading-block') as HTMLElement
|
const heading = container.querySelector('.heading-block') as HTMLElement
|
||||||
await userEvent.click(heading)
|
await userEvent.click(heading)
|
||||||
|
|
||||||
expect(heading.getAttribute('contenteditable')).toBe('true')
|
expect(heading.querySelector('[contenteditable]')?.getAttribute('contenteditable')).toBe('true')
|
||||||
expect(heading.getAttribute('disabled')).toBeNull()
|
expect(heading.getAttribute('disabled')).toBeNull()
|
||||||
})
|
})
|
||||||
// let's assumd if the heading is editable w/in the ResourceCard, the description is editable too
|
// 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 React, {useState} from 'react'
|
||||||
import {Element, useEditor} from '@craftjs/core'
|
import {Element, useEditor, useNode, type Node} from '@craftjs/core'
|
||||||
import {Container} from '../Container'
|
|
||||||
import {NoSections} from '../../common'
|
|
||||||
import {useClassNames} from '../../../../utils'
|
import {useClassNames} from '../../../../utils'
|
||||||
import {type TabBlockProps} from './types'
|
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 TabBlock = ({tabId}: TabBlockProps) => {
|
||||||
const {enabled} = useEditor(state => ({
|
const {enabled} = useEditor(state => ({
|
||||||
enabled: state.options.enabled,
|
enabled: state.options.enabled,
|
||||||
}))
|
}))
|
||||||
const [cid] = useState<string>('tab-block')
|
|
||||||
const clazz = useClassNames(enabled, {empty: false}, ['block', 'tab-block'])
|
const clazz = useClassNames(enabled, {empty: false}, ['block', 'tab-block'])
|
||||||
return (
|
return <Element id={tabId} is={TabContent} canvas={true} className={clazz} />
|
||||||
<Container id={tabId} className={clazz}>
|
|
||||||
<Element
|
|
||||||
id={`${cid}_nosection1`}
|
|
||||||
is={NoSections}
|
|
||||||
canvas={true}
|
|
||||||
className="tab-block__inner"
|
|
||||||
/>
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TabBlock.craft = {
|
TabBlock.craft = {
|
||||||
displayName: 'Tab',
|
displayName: 'Tab',
|
||||||
custom: {
|
custom: {
|
||||||
noToolbar: true,
|
noToolbar: true,
|
||||||
|
notTabContent: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -188,7 +188,9 @@ TabsBlock.craft = {
|
||||||
related: {
|
related: {
|
||||||
toolbar: TabsBlockToolbar,
|
toolbar: TabsBlockToolbar,
|
||||||
},
|
},
|
||||||
custom: {},
|
custom: {
|
||||||
|
notTabContent: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export {TabsBlock}
|
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 {render, waitFor} from '@testing-library/react'
|
||||||
import userEvent, {PointerEventsCheckLevel} from '@testing-library/user-event'
|
import userEvent, {PointerEventsCheckLevel} from '@testing-library/user-event'
|
||||||
import {Editor, Frame} from '@craftjs/core'
|
import {Editor, Frame} from '@craftjs/core'
|
||||||
import {NoSections} from '../../../common/NoSections'
|
import {TabsBlock, TabBlock, TabContent, type TabsBlockProps} from '..'
|
||||||
import {TabsBlock, TabBlock, type TabsBlockProps} from '..'
|
|
||||||
|
|
||||||
const user = userEvent.setup({pointerEventsCheck: PointerEventsCheckLevel.Never})
|
const user = userEvent.setup({pointerEventsCheck: PointerEventsCheckLevel.Never})
|
||||||
|
|
||||||
const renderBlock = (enabled: boolean, props: Partial<TabsBlockProps> = {}) => {
|
const renderBlock = (enabled: boolean, props: Partial<TabsBlockProps> = {}) => {
|
||||||
return render(
|
return render(
|
||||||
<Editor enabled={enabled} resolver={{TabsBlock, TabBlock, NoSections}}>
|
<Editor enabled={enabled} resolver={{TabsBlock, TabBlock, TabContent}}>
|
||||||
<Frame>
|
<Frame>
|
||||||
<TabsBlock {...props} />
|
<TabsBlock {...props} />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
|
@ -16,9 +16,9 @@
|
||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
* 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 {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">
|
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"/>
|
<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 {
|
export {
|
||||||
TabBlock,
|
TabBlock,
|
||||||
|
TabContent,
|
||||||
TabsBlock,
|
TabsBlock,
|
||||||
TabsBlockIcon,
|
TabsBlockIcon,
|
||||||
type TabsBlockProps,
|
type TabsBlockProps,
|
||||||
type TabBlockProps,
|
type TabBlockProps,
|
||||||
type TabsBlockTab,
|
type TabsBlockTab,
|
||||||
type TabVariant,
|
type TabsVariant,
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,14 +19,7 @@
|
||||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||||
import ContentEditable from 'react-contenteditable'
|
import ContentEditable from 'react-contenteditable'
|
||||||
import {useEditor, useNode} from '@craftjs/core'
|
import {useEditor, useNode} from '@craftjs/core'
|
||||||
import {
|
import {useClassNames} from '../../../../utils'
|
||||||
useClassNames,
|
|
||||||
shouldAddNewNode,
|
|
||||||
shouldDeleteNode,
|
|
||||||
addNewNodeAsNextSibling,
|
|
||||||
deleteNodeAndSelectPrevSibling,
|
|
||||||
removeLastParagraphTag,
|
|
||||||
} from '../../../../utils'
|
|
||||||
import {TextBlockToolbar} from './TextBlockToolbar'
|
import {TextBlockToolbar} from './TextBlockToolbar'
|
||||||
import {type TextBlockProps} from './types'
|
import {type TextBlockProps} from './types'
|
||||||
|
|
||||||
|
@ -34,14 +27,15 @@ import {useScope as useI18nScope} from '@canvas/i18n'
|
||||||
|
|
||||||
const I18n = useI18nScope('block-editor/text-block')
|
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) => {
|
export const TextBlock = ({text = '', fontSize, textAlign, color}: TextBlockProps) => {
|
||||||
const {actions, enabled, query} = useEditor(state => ({
|
const {enabled} = useEditor(state => ({
|
||||||
enabled: state.options.enabled,
|
enabled: state.options.enabled,
|
||||||
}))
|
}))
|
||||||
const {
|
const {
|
||||||
connectors: {connect, drag},
|
connectors: {connect, drag},
|
||||||
actions: {setProp},
|
actions: {setProp},
|
||||||
id,
|
|
||||||
selected,
|
selected,
|
||||||
} = useNode(state => ({
|
} = useNode(state => ({
|
||||||
id: state.id,
|
id: state.id,
|
||||||
|
@ -51,7 +45,6 @@ export const TextBlock = ({text = '', fontSize, textAlign, color}: TextBlockProp
|
||||||
const focusableElem = useRef<HTMLDivElement | null>(null)
|
const focusableElem = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
const [editable, setEditable] = useState(true)
|
const [editable, setEditable] = useState(true)
|
||||||
const lastChar = useRef<string>('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editable && selected) {
|
if (editable && selected) {
|
||||||
|
@ -63,8 +56,8 @@ export const TextBlock = ({text = '', fontSize, textAlign, color}: TextBlockProp
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
e => {
|
e => {
|
||||||
let html = e.target.value
|
let html = e.target.value
|
||||||
if (html === '<p><br></p>' || html === '<div><br></div>') {
|
if (!isAParagraph(html)) {
|
||||||
html = ''
|
html = `<p>${html}</p>`
|
||||||
}
|
}
|
||||||
|
|
||||||
setProp((prps: TextBlockProps) => {
|
setProp((prps: TextBlockProps) => {
|
||||||
|
@ -74,25 +67,6 @@ export const TextBlock = ({text = '', fontSize, textAlign, color}: TextBlockProp
|
||||||
[setProp]
|
[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) {
|
if (enabled) {
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
|
// 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}
|
disabled={!editable}
|
||||||
html={text}
|
html={text}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onKeyUp={handleKey}
|
|
||||||
tagName="div"
|
tagName="div"
|
||||||
style={{fontSize, textAlign, color}}
|
style={{fontSize, textAlign, color}}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -34,7 +34,7 @@
|
||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
* 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 {useNode, type Node} from '@craftjs/core'
|
||||||
import {Button, IconButton} from '@instructure/ui-buttons'
|
import {Button, IconButton} from '@instructure/ui-buttons'
|
||||||
import {
|
import {
|
||||||
|
@ -49,13 +49,7 @@ import {Flex} from '@instructure/ui-flex'
|
||||||
import {Menu, type MenuItemProps, type MenuItem} from '@instructure/ui-menu'
|
import {Menu, type MenuItemProps, type MenuItem} from '@instructure/ui-menu'
|
||||||
import {Text} from '@instructure/ui-text'
|
import {Text} from '@instructure/ui-text'
|
||||||
import {type ViewOwnProps} from '@instructure/ui-view'
|
import {type ViewOwnProps} from '@instructure/ui-view'
|
||||||
import {
|
import {isCaretAtBoldText, isCaretAtStyledText, getCaretPosition} from '../../../../utils'
|
||||||
isSelectionAllStyled,
|
|
||||||
isElementBold,
|
|
||||||
makeSelectionBold,
|
|
||||||
unstyleSelection,
|
|
||||||
unboldElement,
|
|
||||||
} from '../../../../utils'
|
|
||||||
import {ColorModal} from '../../common/ColorModal'
|
import {ColorModal} from '../../common/ColorModal'
|
||||||
import {type TextBlockProps} from './types'
|
import {type TextBlockProps} from './types'
|
||||||
|
|
||||||
|
@ -63,6 +57,12 @@ import {useScope as useI18nScope} from '@canvas/i18n'
|
||||||
|
|
||||||
const I18n = useI18nScope('block-editor/text-block')
|
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 TextBlockToolbar = () => {
|
||||||
const {
|
const {
|
||||||
actions: {setProp},
|
actions: {setProp},
|
||||||
|
@ -73,15 +73,60 @@ const TextBlockToolbar = () => {
|
||||||
props: n.data.props as TextBlockProps,
|
props: n.data.props as TextBlockProps,
|
||||||
}))
|
}))
|
||||||
const [colorModalOpen, setColorModalOpen] = useState(false)
|
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(() => {
|
const handleBold = useCallback(() => {
|
||||||
if (isSelectionAllStyled(isElementBold)) {
|
document.execCommand('bold')
|
||||||
unstyleSelection(isElementBold, unboldElement)
|
setIsBold(isCaretAtBoldText())
|
||||||
} else {
|
}, [])
|
||||||
makeSelectionBold()
|
|
||||||
}
|
const handleItalic = useCallback(() => {
|
||||||
setProp((prps: TextBlockProps) => (prps.text = node.dom?.firstElementChild?.innerHTML))
|
document.execCommand('italic')
|
||||||
}, [node.dom, setProp])
|
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(
|
const handleFontSizeChange = useCallback(
|
||||||
(
|
(
|
||||||
|
@ -116,21 +161,32 @@ const TextBlockToolbar = () => {
|
||||||
<IconButton
|
<IconButton
|
||||||
screenReaderLabel="Bold"
|
screenReaderLabel="Bold"
|
||||||
withBackground={false}
|
withBackground={false}
|
||||||
withBorder={false}
|
withBorder={isBold}
|
||||||
onClick={handleBold}
|
onClick={handleBold}
|
||||||
>
|
>
|
||||||
<IconBoldLine size="x-small" />
|
<IconBoldLine size="x-small" />
|
||||||
</IconButton>
|
</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" />
|
<IconItalicLine size="x-small" />
|
||||||
</IconButton>
|
</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" />
|
<IconUnderlineLine size="x-small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
screenReaderLabel={I18n.t('Strikethrough')}
|
screenReaderLabel={I18n.t('Strikethrough')}
|
||||||
withBackground={false}
|
withBackground={false}
|
||||||
withBorder={false}
|
withBorder={isStrikeThrough}
|
||||||
|
onClick={handleStrikeThrough}
|
||||||
>
|
>
|
||||||
<IconStrikethroughLine size="x-small" />
|
<IconStrikethroughLine size="x-small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
|
@ -69,25 +69,6 @@
|
||||||
.font7 {font-family: 'trebuchet ms', geneva;}
|
.font7 {font-family: 'trebuchet ms', geneva;}
|
||||||
.font8 {font-family: verdana, 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 {
|
.colorPalette {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
@ -207,9 +188,24 @@
|
||||||
min-width: 2.5rem;
|
min-width: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paragraph-block {
|
.block {
|
||||||
min-height: 1.5rem;
|
position: relative;
|
||||||
line-height: 1.5rem;
|
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 {
|
.resource-card {
|
||||||
|
@ -347,9 +343,16 @@
|
||||||
.text-block {
|
.text-block {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
|
margin: 12px 0; /* matches Canvas' global p margin */
|
||||||
&.enabled {
|
&.enabled {
|
||||||
text-wrap: stable;
|
text-wrap: stable;
|
||||||
}
|
}
|
||||||
|
p:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.quiz-section {
|
.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/>.
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {KeyboardEvent} from 'react'
|
import {type KeyboardEvent} from 'react'
|
||||||
import {
|
import {
|
||||||
isAnyModifierKeyPressed,
|
isAnyModifierKeyPressed,
|
||||||
getCaretPosition,
|
getCaretPosition,
|
||||||
isCaretAtEnd,
|
isCaretAtEnd,
|
||||||
setCaretToEnd,
|
setCaretToEnd,
|
||||||
|
setCaretToOffset,
|
||||||
shouldAddNewNode,
|
shouldAddNewNode,
|
||||||
removeLastParagraphTag,
|
removeTrailingEmptyParagraphTags,
|
||||||
shouldDeleteNode,
|
shouldDeleteNode,
|
||||||
} from '../kb'
|
} 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', () => {
|
describe('shouldAddNewNode', () => {
|
||||||
it('should return true if this is the 2nd Enter key press', () => {
|
it('should return true if this is the 2nd Enter key press', () => {
|
||||||
const div = document.createElement('div')
|
const div = document.createElement('div')
|
||||||
|
@ -185,22 +206,38 @@ describe('keyboard utilities', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('removeLastParagraphTag', () => {
|
describe('removeTrailingEmptyParagraphTags', () => {
|
||||||
it('should remove the last paragraph tag', () => {
|
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')
|
const div = document.createElement('div')
|
||||||
div.innerHTML = '<p id="p1">hello</p><p id="p2">world</p>'
|
div.innerHTML = '<p id="p1">hello</p><p id="p2">world</p>'
|
||||||
document.body.appendChild(div)
|
document.body.appendChild(div)
|
||||||
removeLastParagraphTag(div)
|
removeTrailingEmptyParagraphTags(div)
|
||||||
expect(div.innerHTML).toBe('<p id="p1">hello</p>')
|
expect(div.innerHTML).toBe('<p id="p1">hello</p><p id="p2">world</p>')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should do nothing if there are no paragraph tags', () => {
|
it('should do nothing if there are no paragraph tags', () => {
|
||||||
const div = document.createElement('div')
|
const div = document.createElement('div')
|
||||||
div.innerHTML = 'hello world'
|
div.innerHTML = 'hello world'
|
||||||
document.body.appendChild(div)
|
document.body.appendChild(div)
|
||||||
removeLastParagraphTag(div)
|
removeTrailingEmptyParagraphTags(div)
|
||||||
expect(div.innerHTML).toBe('hello world')
|
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', () => {
|
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
|
// return trayHeight
|
||||||
// }
|
// }
|
||||||
|
|
||||||
export type isStyledFunction = (node: Element) => boolean
|
// bold is unique because font-weight can be 'bold' or a number
|
||||||
export type unstyleFunction = (node: Element) => void
|
export function isCaretAtBoldText(): boolean {
|
||||||
export type styleSelectionFunction = () => void
|
|
||||||
|
|
||||||
export function makeSelectionBold(): void {
|
|
||||||
unstyleSelection(isElementBold, unboldElement)
|
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (selection?.rangeCount) {
|
if (selection?.rangeCount) {
|
||||||
const range = selection.getRangeAt(0)
|
const range = selection.getRangeAt(0)
|
||||||
const boldNode = document.createElement('span')
|
const caretNode = range.startContainer
|
||||||
boldNode.style.fontWeight = 'bold'
|
const caretElement =
|
||||||
boldNode.appendChild(range.extractContents())
|
caretNode.nodeType === Node.TEXT_NODE ? caretNode.parentElement : (caretNode as Element)
|
||||||
range.insertNode(boldNode)
|
return isElementBold(caretElement)
|
||||||
selection.removeAllRanges()
|
|
||||||
selection.addRange(range)
|
|
||||||
}
|
}
|
||||||
|
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 computedStyle = window.getComputedStyle(elem)
|
||||||
const isBold: boolean =
|
const isBold: boolean =
|
||||||
computedStyle.fontWeight === 'bold' ||
|
computedStyle.fontWeight === 'bold' ||
|
||||||
|
@ -61,114 +58,30 @@ export function isElementBold(elem: Element): boolean {
|
||||||
return isBold
|
return isBold
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unboldElement(elem: Element): void {
|
export function isCaretAtStyledText(property: string, value: string): boolean {
|
||||||
if (
|
const selection = window.getSelection()
|
||||||
elem.tagName === 'B' ||
|
if (selection?.rangeCount) {
|
||||||
elem.tagName === 'STRONG' ||
|
const range = selection.getRangeAt(0)
|
||||||
elem.getAttribute('style')?.split(':').length === 2 // font-weight is the only style attribute
|
const caretNode = range.startContainer
|
||||||
) {
|
const caretElement =
|
||||||
// Replace the <b>, <strong>, or bold-styled tag with its contents
|
caretNode.nodeType === Node.TEXT_NODE ? caretNode.parentElement : (caretNode as Element)
|
||||||
const fragment = document.createDocumentFragment()
|
return isElementOfStyle(property, value, caretElement)
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSelectionAllStyled(styleChecker: isStyledFunction): boolean {
|
export function isElementOfStyle(property: string, value: string, elem: Element | null): boolean {
|
||||||
const selection = window.getSelection()
|
if (!elem) return false
|
||||||
if (!selection || selection.rangeCount === 0) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iterate over all ranges in the selection
|
let currentElem: Element | null = elem
|
||||||
for (let i = 0; i < selection.rangeCount; i++) {
|
while (currentElem) {
|
||||||
const range: Range = selection.getRangeAt(i)
|
const computedStyle = window.getComputedStyle(currentElem)
|
||||||
const commonAncestor: Node = range.commonAncestorContainer
|
if (computedStyle[property] === value) {
|
||||||
|
return true
|
||||||
// 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()
|
|
||||||
}
|
}
|
||||||
|
currentElem = currentElem.parentElement
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scrollIntoViewWithCallback(
|
export function scrollIntoViewWithCallback(
|
||||||
|
|
|
@ -24,3 +24,4 @@ export * from './getScrollParent'
|
||||||
export * from './getCloneTree'
|
export * from './getCloneTree'
|
||||||
export * from './colorUtils'
|
export * from './colorUtils'
|
||||||
export * from './getNodeIndex'
|
export * from './getNodeIndex'
|
||||||
|
export * from './deletable'
|
||||||
|
|
|
@ -21,7 +21,7 @@ import React from 'react'
|
||||||
const isAnyModifierKeyPressed = (event: React.KeyboardEvent) =>
|
const isAnyModifierKeyPressed = (event: React.KeyboardEvent) =>
|
||||||
event.ctrlKey || event.metaKey || event.shiftKey || event.altKey
|
event.ctrlKey || event.metaKey || event.shiftKey || event.altKey
|
||||||
|
|
||||||
const getCaretPosition = (editableElement: HTMLElement): number => {
|
const getCaretPosition = (editableElement: Element): number => {
|
||||||
let caretOffset = 0
|
let caretOffset = 0
|
||||||
const doc = editableElement.ownerDocument
|
const doc = editableElement.ownerDocument
|
||||||
const win = doc.defaultView
|
const win = doc.defaultView
|
||||||
|
@ -62,20 +62,21 @@ const setCaretToEnd = (editableElement: HTMLElement) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// const setCaretToOffset = (editableElement: HTMLElement, offset: number) => {
|
const setCaretToOffset = (element: HTMLElement, offset: number) => {
|
||||||
// const range = document.createRange()
|
const range = document.createRange()
|
||||||
// const sel = window.getSelection()
|
const sel = window.getSelection()
|
||||||
// if (sel) {
|
if (sel) {
|
||||||
// const textNode = editableElement.querySelector('p')?.firstChild
|
const textNode = element.firstChild
|
||||||
// if (textNode) {
|
|
||||||
// range.setStart(textNode, offset)
|
|
||||||
// range.collapse(true)
|
|
||||||
|
|
||||||
// sel.removeAllRanges()
|
if (textNode) {
|
||||||
// sel.addRange(range)
|
range.setStart(textNode, offset)
|
||||||
// }
|
range.collapse(true)
|
||||||
// }
|
|
||||||
// }
|
sel.removeAllRanges()
|
||||||
|
sel.addRange(range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const addNewNodeAsNextSibling = (
|
const addNewNodeAsNextSibling = (
|
||||||
newNode: React.ReactElement,
|
newNode: React.ReactElement,
|
||||||
|
@ -96,17 +97,21 @@ const shouldAddNewNode = (e: React.KeyboardEvent, lastChar: string) => {
|
||||||
if (!e.currentTarget.textContent) return false
|
if (!e.currentTarget.textContent) return false
|
||||||
return (
|
return (
|
||||||
e.key === 'Enter' &&
|
e.key === 'Enter' &&
|
||||||
|
lastChar === 'Enter' &&
|
||||||
!isAnyModifierKeyPressed(e) &&
|
!isAnyModifierKeyPressed(e) &&
|
||||||
isCaretAtEnd(e.currentTarget as HTMLElement) &&
|
isCaretAtEnd(e.currentTarget as HTMLElement)
|
||||||
lastChar === 'Enter'
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeLastParagraphTag = (elem: HTMLElement) => {
|
const removeTrailingEmptyParagraphTags = (elem: HTMLElement) => {
|
||||||
const paras = elem.querySelectorAll('p')
|
const paras = elem.querySelectorAll('p')
|
||||||
if (paras.length > 0) {
|
if (paras.length > 0) {
|
||||||
const lastPara = paras[paras.length - 1]
|
let lastPara = elem.lastElementChild
|
||||||
lastPara.remove()
|
// 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,
|
getCaretPosition,
|
||||||
isCaretAtEnd,
|
isCaretAtEnd,
|
||||||
setCaretToEnd,
|
setCaretToEnd,
|
||||||
|
setCaretToOffset,
|
||||||
shouldAddNewNode,
|
shouldAddNewNode,
|
||||||
shouldDeleteNode,
|
shouldDeleteNode,
|
||||||
addNewNodeAsNextSibling,
|
addNewNodeAsNextSibling,
|
||||||
deleteNodeAndSelectPrevSibling,
|
deleteNodeAndSelectPrevSibling,
|
||||||
removeLastParagraphTag,
|
removeTrailingEmptyParagraphTags,
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,7 +54,14 @@ KeyboardNavDialog.prototype.bindOpenKeys = function () {
|
||||||
(function (_this) {
|
(function (_this) {
|
||||||
return function (e) {
|
return function (e) {
|
||||||
const isQuestionMark = e.keyCode === 191 && e.shiftKey
|
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()
|
e.preventDefault()
|
||||||
if (_this.$el.is(':visible')) {
|
if (_this.$el.is(':visible')) {
|
||||||
_this.$el.dialog('close')
|
_this.$el.dialog('close')
|
||||||
|
|
Loading…
Reference in New Issue