Polish block editor work

closes RCX-2048
flag=block_editor

test plan:
  - with the block_editor flag on
  - edit a page
  - scroll down
  > expect the top bar to stop scrolling when it hits the top of the page
  > expect the About, Hero, and Navigation sections to have a toolbar
    with a background color button
  - click the button and change the color from the popup
  > expect to see the new background color
  - click on a text block
  > expect a new font size menu button in the toolbar
  > expect the menu to change the size of the text
  - select a section
  - move the mouse around
  > expect a tag to pop up labeling the block you're hovering over
  - click on a Heading block
  - open the heading level menu from the heading block toolbar
  > expect the current level to have a checkmark
  - create a new page using the stepper
  > expect the page to be scrolled to the top, and no section selected
    once the page is created
  - grab a section or block from the tray
  > expect a grabbing cursor
  - start creating a new page
  - cancel from the Create a new page modal
  > expect to be back on the index page

Change-Id: Ib6735e139b34ca84ca71e54f32360e468999c3ff
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/350004
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Jacob DeWar <jacob.dewar@instructure.com>
QA-Review: Jacob DeWar <jacob.dewar@instructure.com>
Product-Review: Ed Schiebel <eschiebel@instructure.com>
This commit is contained in:
Ed Schiebel 2024-06-12 15:49:18 -06:00
parent 8e957aa398
commit 895f75549d
31 changed files with 611 additions and 290 deletions

View File

@ -5,7 +5,7 @@
"author": "neme",
"main": "./react/index.tsx",
"dependencies": {
"@craftjs/core": "^0.2.7",
"@craftjs/core": "^0.2.8",
"@instructure/ui-color-picker": "^8",
"styled-components": ">= 4",
"react-contenteditable": "^3.3.7",

View File

@ -50,11 +50,17 @@ type BlockEditorProps = {
enabled?: boolean
version: string
content: string
onCancel: () => void
}
export default function BlockEditor({enabled = true, version, content}: BlockEditorProps) {
export default function BlockEditor({
enabled = true,
version,
content,
onCancel,
}: BlockEditorProps) {
const [json] = useState(content || DEFAULT_CONTENT)
const [toolboxOpen, setToolboxOpen] = useState(true)
const [toolboxOpen, setToolboxOpen] = useState(false)
const [stepperOpen, setStepperOpen] = useState(!content)
useEffect(() => {
@ -80,8 +86,14 @@ export default function BlockEditor({enabled = true, version, content}: BlockEdi
const handleCloseStepper = useCallback(() => {
setStepperOpen(false)
setToolboxOpen(true)
}, [])
const handleCancelStepper = useCallback(() => {
setStepperOpen(false)
onCancel()
}, [onCancel])
return (
<View
as="div"
@ -101,16 +113,20 @@ export default function BlockEditor({enabled = true, version, content}: BlockEdi
onRender={RenderNode}
>
<Flex direction="column" alignItems="stretch" justifyItems="start" gap="small" width="100%">
<Flex.Item shouldGrow={false}>
<div style={{position: 'sticky', top: 0, zIndex: 9999}}>
<Topbar onToolboxChange={handleOpenToolbox} toolboxOpen={toolboxOpen} />
</Flex.Item>
</div>
<Flex.Item id="editor-area" shouldGrow={true}>
<Frame data={json} />
</Flex.Item>
</Flex>
<Toolbox open={toolboxOpen} onClose={handleCloseToolbox} />
<NewPageStepper open={stepperOpen} onDismiss={handleCloseStepper} />
<NewPageStepper
open={stepperOpen}
onFinish={handleCloseStepper}
onCancel={handleCancelStepper}
/>
</Editor>
</View>
)

View File

@ -0,0 +1,42 @@
/*
* 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 React from 'react'
import {SVGIcon} from '@instructure/ui-svg-images'
import {type IconProps} from '../iconTypes'
export default ({elementRef, size = 'small'}: IconProps) => {
return (
<SVGIcon
elementRef={elementRef}
src={`<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_13_7929)">
<path d="M5.52715 0L4.48118 1.01914L5.82475 2.33149L0.672221 7.36027C-0.224074 8.23479 -0.224074 9.6186 0.672221 10.4931L0.74662 10.5671L5.45129 15.1575C6.34759 16.032 7.7673 16.032 8.6636 15.1575L14.3398 9.61917L14.8636 9.1096L7.61763 2.0397L7.02097 1.45754L6.87072 1.31093L5.52715 0ZM6.87072 3.35205L12.7716 9.1096L11.129 10.7123H2.9859L1.71673 9.47398C1.41797 9.18248 1.41797 8.67234 1.71673 8.38083L6.87072 3.35205ZM15.7593 11.6603L15.1611 12.5342C15.1611 12.5342 14.788 13.1167 14.3398 13.7726C14.1158 14.137 13.967 14.4288 13.8176 14.7931C13.6682 15.1575 13.5185 15.3764 13.5185 15.8137C13.5185 16.9797 14.5642 18 15.7593 18C16.9543 18 18 16.9797 18 15.8137C18 15.3764 17.8503 15.0849 17.7009 14.7205C17.5516 14.3562 17.3281 13.9915 17.1787 13.7C16.8052 13.0441 16.3574 12.4616 16.3574 12.4616L15.7593 11.6603Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_13_7929">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
</svg>
`}
size={size}
/>
)
}

View File

@ -18,5 +18,6 @@
import IconUndo from './undo'
import IconRedo from './redo'
import IconBackgroundColor from './background-color'
export {IconUndo, IconRedo}
export {IconUndo, IconRedo, IconBackgroundColor}

View File

@ -20,10 +20,11 @@ import React, {useCallback, useState} from 'react'
import tinycolor from 'tinycolor2'
import {ColorIndicator, ColorMixer, ColorPreset} from '@instructure/ui-color-picker'
import {FormFieldGroup, type FormMessage} from '@instructure/ui-form-field'
import {TextInput} from '@instructure/ui-text-input'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
import {TextInput, type TextInputOwnProps} from '@instructure/ui-text-input'
type ColorPickerProps = {
label: string
label: React.ReactNode
disabled: boolean
value: string
onChange: (color: string) => void
@ -57,7 +58,7 @@ const ColorPicker = ({label, disabled, value, onChange}: ColorPickerProps) => {
)
const handleHexKey = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
(event: React.KeyboardEvent<TextInputOwnProps>) => {
if (event.key === 'Enter') {
const color = tinycolor(typedColor)
if (color.isValid(typedColor)) {
@ -79,7 +80,7 @@ const ColorPicker = ({label, disabled, value, onChange}: ColorPickerProps) => {
return (
<FormFieldGroup layout="stacked" description={label} rowSpacing="small">
<TextInput
renderLabel="Enter a color"
renderLabel={<ScreenReaderContent>Custom color</ScreenReaderContent>}
interaction={disabled ? 'disabled' : 'enabled'}
value={typedColor}
messages={messages}

View File

@ -30,15 +30,16 @@ import {PageSections} from './PageSections'
import {ColorPalette} from './ColorPalette'
import {FontPairings} from './FontPairings'
import {PageTemplates} from './PageTemplates'
import {buildPageContent} from '../../../utils'
import {buildPageContent, getScrollParent} from '../../../utils'
import {type PageSection} from './types'
type NewPageStepperProps = {
open: boolean
onDismiss: () => void
onFinish: () => void
onCancel: () => void
}
const NewPageStepper = ({open, onDismiss}: NewPageStepperProps) => {
const NewPageStepper = ({open, onFinish, onCancel}: NewPageStepperProps) => {
const {actions, query} = useEditor()
const [step, setStep] = useState(0)
const [startingPoint, setStartingPoint] = useState<Step1Selection>('scratch')
@ -51,9 +52,20 @@ const NewPageStepper = ({open, onDismiss}: NewPageStepperProps) => {
setStep(step + 1)
} else {
buildPageContent(actions, query, selectedSections, paletteId, fontName)
onDismiss()
onFinish()
}
}, [actions, fontName, onDismiss, paletteId, query, selectedSections, step])
}, [actions, fontName, onFinish, paletteId, query, selectedSections, step])
// buildPageContent returns before the Editor renders all the new stuff.
// I think that because of javascript's single-threaded nature, onDismiss doesn't
// unmount the modal until craftjs is finished rendering all the new nodes.
// Use that opportunity to unselect the last created node and scroll to the top
const handleClosed = useCallback(() => {
// @ts-expect-error (null is OK)
actions.selectNode(null)
const scrollingContainer = getScrollParent()
scrollingContainer.scrollTo({top: 0, behavior: 'instant'})
}, [actions])
const handlePrevStep = useCallback(() => {
setStep(step - 1)
@ -100,12 +112,12 @@ const NewPageStepper = ({open, onDismiss}: NewPageStepperProps) => {
}
return (
<Modal open={open} label="Create a new page" onDismiss={onDismiss}>
<Modal open={open} label="Create a new page" onDismiss={onCancel} onClose={handleClosed}>
<Modal.Header>
<Heading>Create a new page</Heading>
<CloseButton
data-instui-modal-close-button="true"
onClick={onDismiss}
onClick={onCancel}
screenReaderLabel="Close"
placement="end"
offset="medium"
@ -126,7 +138,7 @@ const NewPageStepper = ({open, onDismiss}: NewPageStepperProps) => {
</View>
</Modal.Body>
<Modal.Footer>
<Button color="secondary" onClick={onDismiss}>
<Button color="secondary" onClick={onCancel}>
Cancel
</Button>
<Button color="primary" margin="0 0 0 small" onClick={handleNextStep}>

View File

@ -48,8 +48,9 @@ import {ROOT_NODE} from '@craftjs/utils'
import {IconArrowUpLine, IconTrashLine, IconDragHandleLine} from '@instructure/ui-icons'
import {IconButton} from '@instructure/ui-buttons'
import {Text} from '@instructure/ui-text'
import {type ViewProps} from '@instructure/ui-view'
import {View, type ViewProps} from '@instructure/ui-view'
import {ToolbarSeparator} from './ToolbarSeparator'
import {getScrollParent} from '../../utils'
const findUpNode = (node: Node, query: any): Node | undefined => {
let upnode = node.data.parent ? query.node(node.data.parent).get() : undefined
@ -68,11 +69,17 @@ const findContainingSection = (node: Node, query: any): Node | undefined => {
return upnode && upnode.data.custom?.isSection ? upnode : undefined
}
type RenderNodeProps = {
interface RenderNodeProps {
render: React.ReactElement
}
export const RenderNode = ({render}: RenderNodeProps) => {
interface RenderNodeComponent extends React.FC<RenderNodeProps> {
globals: {
selectedSectionId: string
}
}
export const RenderNode: RenderNodeComponent = ({render}: RenderNodeProps) => {
const {actions, query} = useEditor(state => {
if (state.events.selected.size === 0) {
RenderNode.globals.selectedSectionId = ''
@ -100,7 +107,7 @@ export const RenderNode = ({render}: RenderNodeProps) => {
props: n.data.props,
}))
const [currentToolbarRef, setCurrentToolbarRef] = useState<HTMLDivElement | null>(null)
const [currentToolbarOrTagRef, setCurrentToolbarOrTagRef] = useState<HTMLDivElement | null>(null)
const [currentMenuRef, setCurrentMenuRef] = useState<HTMLDivElement | null>(null)
const [upnodeId] = useState<string | undefined>(findUpNode(node, query)?.id)
@ -150,16 +157,21 @@ export const RenderNode = ({render}: RenderNodeProps) => {
const getToolbarPos = useCallback(
(domNode: HTMLElement | null) => {
const {top, left, bottom} = domNode
const {top, left, height} = domNode
? domNode.getBoundingClientRect()
: {top: 0, left: 0, bottom: 0}
const offset = currentToolbarRef ? currentToolbarRef.getBoundingClientRect().height : 0
: {top: 0, left: 0, height: 0}
const bottom = top + height
// 5 is the offset of the hover/focus rings
const offset = currentToolbarOrTagRef
? currentToolbarOrTagRef.getBoundingClientRect().height + 5
: 0
return {
top: `${top > 0 ? top - offset : bottom - offset}px`,
left: `${left}px`,
}
},
[currentToolbarRef]
[currentToolbarOrTagRef]
)
const getMenuPos = useCallback(
(domNode: HTMLElement | null) => {
@ -181,19 +193,19 @@ export const RenderNode = ({render}: RenderNodeProps) => {
currentMenuRef.style.top = top
currentMenuRef.style.left = left
}
if (currentToolbarRef) {
if (currentToolbarOrTagRef) {
const {top, left} = getToolbarPos(dom)
currentToolbarRef.style.top = top
currentToolbarRef.style.left = left
currentToolbarOrTagRef.style.top = top
currentToolbarOrTagRef.style.left = left
}
}, [currentMenuRef, currentToolbarRef, dom, getMenuPos, getToolbarPos])
}, [currentMenuRef, currentToolbarOrTagRef, dom, getMenuPos, getToolbarPos])
useEffect(() => {
const scroller = document.getElementById('drawer-layout-content') || document
scroller.addEventListener('scroll', scroll)
const scrollingContainer = getScrollParent()
scrollingContainer.addEventListener('scroll', scroll)
return () => {
scroller.removeEventListener('scroll', scroll)
scrollingContainer.removeEventListener('scroll', scroll)
}
}, [dom, scroll])
@ -222,15 +234,16 @@ export const RenderNode = ({render}: RenderNodeProps) => {
return ReactDOM.createPortal(
<div
ref={(el: HTMLDivElement) => setCurrentToolbarRef(el)}
ref={(el: HTMLDivElement) => setCurrentToolbarOrTagRef(el)}
className="block-toolbar"
style={{
left: getToolbarPos(dom).left,
top: getToolbarPos(dom).top,
}}
data-timestamp={Date.now()}
>
<Text size="small">{name}</Text>
<View as="div" background="brand" padding="0 xx-small" borderRadius="small">
<Text size="small">{name}</Text>
</View>
{moveable ? (
<IconButton
cursor="move"
@ -302,6 +315,35 @@ export const RenderNode = ({render}: RenderNodeProps) => {
: null
}
const renderHoverTag = () => {
if (node.data?.custom?.noToolbar) return null
const parentSection = findContainingSection(node, query)
if (!parentSection) return null
const isMySectionSelected = RenderNode.globals.selectedSectionId === parentSection.id
if (!isMySectionSelected) return null
const mountPoint = document.querySelector('.block-editor-editor')
if (!mountPoint) return null
return ReactDOM.createPortal(
<div
ref={(el: HTMLDivElement) => setCurrentToolbarOrTagRef(el)}
className="block-tag"
style={{
left: getToolbarPos(dom).left,
top: getToolbarPos(dom).top,
}}
>
<View as="div" background="secondary" padding="0 xx-small" borderRadius="small">
<Text size="small">{name}</Text>
</View>
</div>,
mountPoint
)
}
const renderRelated = () => {
return (
<>
@ -314,6 +356,7 @@ export const RenderNode = ({render}: RenderNodeProps) => {
return (
<>
{selected && node.related && renderRelated()}
{!selected && hovered && renderHoverTag()}
{render}
</>
)

View File

@ -19,12 +19,12 @@
import React, {useCallback} from 'react'
import {useEditor, type Node} from '@craftjs/core'
import {Menu} from '@instructure/ui-menu'
import {getCloneTree, scrollIntoViewWithCallback} from '../../utils'
import {getCloneTree, scrollIntoViewWithCallback, getScrollParent} from '../../utils'
function triggerScrollEvent() {
const scroller = document.getElementById('drawer-layout-content') || document
const scrollingContainer = getScrollParent()
const scrollEvent = new Event('scroll')
scroller.dispatchEvent(scrollEvent)
scrollingContainer.dispatchEvent(scrollEvent)
}
type SectionMenuProps = {
@ -49,7 +49,9 @@ const SectionMenu = ({
})
const handleEditSection = useCallback(() => {
onEditSection(selected.get())
if (onEditSection) {
onEditSection(selected.get())
}
}, [onEditSection, selected])
const handleDuplicateSection = useCallback(() => {
@ -118,8 +120,10 @@ const SectionMenu = ({
const handleRemove = useCallback(() => {
if (onRemove) {
onRemove(selected.get())
} else {
actions.delete(selected.get().id)
} else if (selected.get()?.id) {
window.setTimeout(() => {
actions.delete(selected.get().id)
}, 0)
}
}, [actions, onRemove, selected])

View File

@ -46,8 +46,6 @@ import {QuizSection, QuizSectionIcon} from '../user/sections/QuizSection'
import {FooterSection, FooterSectionIcon} from '../user/sections/FooterSection'
import {BlankSection, BlankSectionIcon} from '../user/sections/BlankSection'
import {getTrayHeight} from '../../utils'
type ToolboxProps = {
open: boolean
onClose: () => void
@ -117,64 +115,63 @@ export const Toolbox = ({open, onClose}: ToolboxProps) => {
<Tray
contentRef={el => setTrayRef(el)}
label="Toolbox"
mountNode={document.querySelector('.block-editor-editor') as HTMLElement}
open={open}
placement="end"
size="small"
onClose={handleCloseTray}
>
<Flex direction="column" height={getTrayHeight()}>
<Flex.Item shouldGrow={true} shouldShrink={true}>
<CloseButton placement="end" onClick={handleCloseTray} screenReaderLabel="Close" />
<Tabs onRequestTabChange={handleTabChange}>
<Tabs.Panel renderTitle="Blocks" isSelected={activeTab === 0}>
<Flex
gap="x-small"
justifyItems="space-between"
alignItems="center"
wrap="wrap"
padding="x-small"
>
{renderBox('Button', ButtonBlockIcon, <ButtonBlock text="Click me" />)}
{renderBox('Text', TextBlockIcon, <TextBlock text="" />)}
{renderBox('RCE', RCEBlockIcon, <RCEBlock text="" />)}
{/* renderBox(
<View as="div">
<CloseButton placement="end" onClick={handleCloseTray} screenReaderLabel="Close" />
<Tabs onRequestTabChange={handleTabChange}>
<Tabs.Panel renderTitle="Blocks" isSelected={activeTab === 0}>
<Flex
gap="x-small"
justifyItems="space-between"
alignItems="center"
wrap="wrap"
padding="x-small"
>
{renderBox('Button', ButtonBlockIcon, <ButtonBlock text="Click me" />)}
{renderBox('Text', TextBlockIcon, <TextBlock text="" />)}
{renderBox('RCE', RCEBlockIcon, <RCEBlock text="" />)}
{/* renderBox(
'Container',
ContainerIcon,
<Element is={Container} background="#fff" canvas={true} layout="row" />
) */}
{renderBox('Icon', IconBlockIcon, <IconBlock iconName="apple" />)}
{renderBox('Heading', HeadingBlockIcon, <HeadingBlock />)}
{renderBox('Resource Card', ResourceCardIcon, <ResourceCard />)}
{renderBox('Image', ImageBlockIcon, <ImageBlock />)}
{renderBox('Iframe', IframeBlockIcon, <IframeBlock />)}
</Flex>
</Tabs.Panel>
<Tabs.Panel renderTitle="Sections" isSelected={activeTab === 1}>
<Flex
gap="x-small"
justifyItems="space-between"
alignItems="center"
wrap="wrap"
width="320px"
padding="x-small"
>
{renderBox('Resources', ResourcesSectionIcon, <ResourcesSection />)}
{renderBox(
'Columns',
ColumnsSectionIcon,
<ColumnsSection columns={2} variant="fixed" />
)}
{renderBox('Blank', BlankSectionIcon, <BlankSection />)}
{renderBox('Hero', ImageBlockIcon, <HeroSection />)}
{renderBox('Navigation', NavigationSectionIcon, <NavigationSection />)}
{renderBox('About', AboutSectionIcon, <AboutSection />)}
{renderBox('Quiz', QuizSectionIcon, <QuizSection />)}
{renderBox('Footer', FooterSectionIcon, <FooterSection />)}
</Flex>
</Tabs.Panel>
</Tabs>
</Flex.Item>
</Flex>
{renderBox('Icon', IconBlockIcon, <IconBlock iconName="apple" />)}
{renderBox('Heading', HeadingBlockIcon, <HeadingBlock />)}
{renderBox('Resource Card', ResourceCardIcon, <ResourceCard />)}
{renderBox('Image', ImageBlockIcon, <ImageBlock />)}
{renderBox('Iframe', IframeBlockIcon, <IframeBlock />)}
</Flex>
</Tabs.Panel>
<Tabs.Panel renderTitle="Sections" isSelected={activeTab === 1}>
<Flex
gap="x-small"
justifyItems="space-between"
alignItems="center"
wrap="wrap"
width="320px"
padding="x-small"
>
{renderBox('Resources', ResourcesSectionIcon, <ResourcesSection />)}
{renderBox(
'Columns',
ColumnsSectionIcon,
<ColumnsSection columns={2} variant="fixed" />
)}
{renderBox('Blank', BlankSectionIcon, <BlankSection />)}
{renderBox('Hero', ImageBlockIcon, <HeroSection />)}
{renderBox('Navigation', NavigationSectionIcon, <NavigationSection />)}
{renderBox('About', AboutSectionIcon, <AboutSection />)}
{renderBox('Quiz', QuizSectionIcon, <QuizSection />)}
{renderBox('Footer', FooterSectionIcon, <FooterSection />)}
</Flex>
</Tabs.Panel>
</Tabs>
</View>
</Tray>
)
}

View File

@ -30,7 +30,7 @@ import {
} from '@instructure/ui-icons'
import {LinkModal} from '../../../editor/LinkModal'
import {ColorModal} from './ColorModal'
import {ColorModal} from '../../common/ColorModal'
import {isInstuiButtonColor} from './common'
import type {ButtonBlockProps, ButtonSize, ButtonVariant} from './common'
@ -196,6 +196,7 @@ const ButtonBlockToolbar = () => {
<ColorModal
open={colorModalOpen}
color={props.color}
variant="button"
onClose={handleCloseColorModal}
onSubmit={handleColorChange}
/>

View File

@ -1,130 +0,0 @@
/*
* 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 React, {useCallback, useState} from 'react'
import {Button, CloseButton} from '@instructure/ui-buttons'
import {RadioInput} from '@instructure/ui-radio-input'
import {Flex} from '@instructure/ui-flex'
import {Heading} from '@instructure/ui-heading'
import {Modal} from '@instructure/ui-modal'
import {View} from '@instructure/ui-view'
import {isInstuiButtonColor} from './common'
import {ColorPicker} from '../../../editor/ColorPicker'
import {FormFieldGroup} from '@instructure/ui-form-field'
type LinkModalProps = {
open: boolean
color: string
onClose: () => void
onSubmit: (color: string) => void
}
const ColorModal = ({open, color, onClose, onSubmit}: LinkModalProps) => {
const [currColor, setCurrColor] = useState(color)
const handleColorChange = useCallback((newColor: string) => {
setCurrColor(newColor)
}, [])
const handleButtonColorChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
handleColorChange(event.target.value)
},
[handleColorChange]
)
const handleSubmit = useCallback(() => {
onSubmit(currColor)
}, [onSubmit, currColor])
return (
<Modal open={open} onDismiss={onClose} label="Link" size="small">
<Modal.Header>
<Heading level="h2">Select an Button Color</Heading>
<CloseButton placement="end" onClick={onClose} screenReaderLabel="Close" />
</Modal.Header>
<Modal.Body padding="medium">
<FormFieldGroup description="Standard Button Colors">
<Flex as="div" margin="small" gap="small" wrap="wrap">
<RadioInput
value="primary"
label="Primary"
inline={true}
onChange={handleButtonColorChange}
checked={currColor === 'primary'}
/>
<RadioInput
value="secondary"
label="Secondary"
inline={true}
onChange={handleButtonColorChange}
checked={currColor === 'secondary'}
/>
<RadioInput
value="success"
label="Success"
inline={true}
onChange={handleButtonColorChange}
checked={currColor === 'success'}
/>
<RadioInput
value="danger"
label="Danger"
inline={true}
onChange={handleButtonColorChange}
checked={currColor === 'danger'}
/>
<RadioInput
value="primary-inverse"
label="Primary Inverse"
inline={true}
onChange={handleButtonColorChange}
checked={currColor === 'primary-inverse'}
/>
<RadioInput
value="custom"
label="Custom"
inline={true}
onChange={handleButtonColorChange}
checked={!isInstuiButtonColor(currColor)}
/>
</Flex>
</FormFieldGroup>
<View as="div" margin="small 0 0 0" borderWidth="small 0 0 0" padding="small 0 0 0">
<ColorPicker
label="Custom Color"
onChange={handleColorChange}
value={currColor}
disabled={isInstuiButtonColor(currColor)}
/>
</View>
</Modal.Body>
<Modal.Footer>
<Button color="secondary" onClick={onClose}>
Cancel
</Button>
<Button color="primary" onClick={handleSubmit} margin="0 0 0 small">
Set Color
</Button>
</Modal.Footer>
</Modal>
)
}
export {ColorModal}

View File

@ -19,17 +19,7 @@ import React from 'react'
import {useEditor, useNode} from '@craftjs/core'
import {useClassNames} from '../../../../utils'
import {ContainerSettings} from './ContainerSettings'
export type ContainerLayout = 'default' | 'row' | 'column'
export type ContainerProps = {
id?: string
className?: string
'data-placeholder'?: string
background?: string
style?: React.CSSProperties
layout?: ContainerLayout
children?: React.ReactNode
}
import {type ContainerProps} from './types'
export const Container = ({
id,

View File

@ -18,6 +18,7 @@
import {Container} from './Container'
import {ContainerSettings} from './ContainerSettings'
import {type ContainerProps, type ContainerLayout} from './types'
const ContainerIcon = `
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
@ -27,4 +28,4 @@ const ContainerIcon = `
</svg>
`
export {Container, ContainerSettings, ContainerIcon}
export {Container, ContainerSettings, ContainerIcon, type ContainerProps, type ContainerLayout}

View File

@ -0,0 +1,30 @@
/*
* 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 React from 'react'
export type ContainerLayout = 'default' | 'row' | 'column'
export type ContainerProps = {
id?: string
className?: string
'data-placeholder'?: string
background?: string
style?: React.CSSProperties
layout?: ContainerLayout
children?: React.ReactNode
}

View File

@ -39,7 +39,7 @@ import {useNode} from '@craftjs/core'
import {Button} from '@instructure/ui-buttons'
import {Flex} from '@instructure/ui-flex'
import {Menu, type MenuItemProps, type MenuItem} from '@instructure/ui-menu'
import {IconArrowOpenDownLine} from '@instructure/ui-icons'
import {IconMiniArrowDownLine} from '@instructure/ui-icons'
import {Text} from '@instructure/ui-text'
import {type ViewOwnProps} from '@instructure/ui-view'
@ -68,20 +68,35 @@ const HeadingBlockToolbar = () => {
label="Heading level"
trigger={
<Button size="small">
<Flex gap="small">
<Flex gap="x-small">
<Text size="small">Level</Text>
<IconArrowOpenDownLine size="x-small" />
<IconMiniArrowDownLine size="x-small" />
</Flex>
</Button>
}
>
<Menu.Item value="h2" onSelect={handleLevelChange}>
<Menu.Item
type="checkbox"
value="h2"
onSelect={handleLevelChange}
selected={props.level === 'h2'}
>
<Text size="small">Heading 2</Text>
</Menu.Item>
<Menu.Item value="h3" onSelect={handleLevelChange}>
<Menu.Item
type="checkbox"
value="h3"
onSelect={handleLevelChange}
selected={props.level === 'h3'}
>
<Text size="small">Heading 3</Text>
</Menu.Item>
<Menu.Item value="h4" onSelect={handleLevelChange}>
<Menu.Item
type="checkbox"
value="h4"
onSelect={handleLevelChange}
selected={props.level === 'h4'}
>
<Text size="small">Heading 4</Text>
</Menu.Item>
</Menu>

View File

@ -17,7 +17,7 @@
*/
import React, {useCallback, useEffect} from 'react'
import {useEditor, useNode, type Node} from '@craftjs/core'
import {useClassNames} from '../../../../utils'
import {useClassNames, getScrollParent} from '../../../../utils'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
export type PageBlockProps = {
@ -37,6 +37,20 @@ export const PageBlock = ({children}: PageBlockProps) => {
} = useNode()
const clazz = useClassNames(enabled, {empty: !children}, ['page-block'])
// So that a section newly dropped in the editor gets selected,
// RenderNode selects them on initial render. As a side-effect this also
// happens as the initial json is loaded.
// This unselects whatever was last and scrolls to the top.
useEffect(() => {
requestAnimationFrame(() => {
// @ts-expect-error (null is allowed)
actions.selectNode(null)
const scrollingContainer = getScrollParent()
scrollingContainer.scrollTo({top: 0, behavior: 'instant'})
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handlePagekey = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {

View File

@ -32,14 +32,14 @@ import {TextBlockToolbar} from './TextBlockToolbar'
type TextBlockProps = {
text?: string
fontSize?: number
fontSize?: string
textAlign?: string
color?: string
}
export const TextBlock = ({
text = '',
fontSize,
fontSize = '12pt',
textAlign = 'start',
color = black,
}: TextBlockProps) => {
@ -124,7 +124,7 @@ export const TextBlock = ({
onChange={handleChange}
onKeyUp={handleKey}
tagName="div"
style={{fontSize: `${fontSize}px`, textAlign, color}}
style={{fontSize, textAlign, color}}
/>
</div>
)
@ -132,7 +132,7 @@ export const TextBlock = ({
return (
<div
className={clazz}
style={{fontSize: `${fontSize}px`, textAlign, color}}
style={{fontSize, textAlign, color}}
dangerouslySetInnerHTML={{__html: text}}
/>
)

View File

@ -36,13 +36,18 @@
import React, {useCallback} from 'react'
import {useNode} from '@craftjs/core'
import {IconButton} from '@instructure/ui-buttons'
import {Button, IconButton} from '@instructure/ui-buttons'
import {
IconBoldLine,
IconItalicLine,
IconUnderlineLine,
IconStrikethroughLine,
IconMiniArrowDownLine,
} from '@instructure/ui-icons'
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,
@ -70,6 +75,18 @@ const TextBlockToolbar = () => {
setProp(prps => (prps.text = node.dom?.firstElementChild?.innerHTML))
}, [node.dom, setProp])
const handleFontSizeChange = useCallback(
(
e: React.MouseEvent<ViewOwnProps, MouseEvent>,
value: MenuItemProps['value'] | MenuItemProps['value'][],
_selected: MenuItemProps['selected'],
_args: MenuItem
) => {
setProp(prps => (prps.fontSize = value))
},
[setProp]
)
return (
<>
<IconButton
@ -89,6 +106,29 @@ const TextBlockToolbar = () => {
<IconButton screenReaderLabel="Strikethrough" withBackground={false} withBorder={false}>
<IconStrikethroughLine size="x-small" />
</IconButton>
<Menu
label="Font size"
trigger={
<Button size="small">
<Flex gap="x-small">
<Text size="small">{props.fontSize || 'Size'}</Text>
<IconMiniArrowDownLine size="x-small" />
</Flex>
</Button>
}
>
{['8pt', '10pt', '12pt', '14pt', '18ps', '24pt', '36pt'].map(size => (
<Menu.Item
type="checkbox"
key={size}
value={size}
onSelect={handleFontSizeChange}
selected={props.fontSize === size}
>
<Text size="small">{size}pt</Text>
</Menu.Item>
))}
</Menu>
</>
)
}

View File

@ -0,0 +1,144 @@
/*
* 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 React, {useCallback, useState} from 'react'
import {Button, CloseButton} from '@instructure/ui-buttons'
import {RadioInput} from '@instructure/ui-radio-input'
import {Flex} from '@instructure/ui-flex'
import {Heading} from '@instructure/ui-heading'
import {Modal} from '@instructure/ui-modal'
import {View} from '@instructure/ui-view'
import {isInstuiButtonColor} from '../blocks/ButtonBlock/common'
import {ColorPicker} from '../../editor/ColorPicker'
import {FormFieldGroup} from '@instructure/ui-form-field'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
export type ColorModalVariant = 'background' | 'button'
type ColorModalProps = {
open: boolean
color: string
variant: ColorModalVariant
onClose: () => void
onSubmit: (color: string) => void
}
const ColorModal = ({open, color, variant, onClose, onSubmit}: ColorModalProps) => {
const [currColor, setCurrColor] = useState(color)
const handleColorChange = useCallback((newColor: string) => {
setCurrColor(newColor)
}, [])
const handleButtonColorChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
handleColorChange(event.target.value)
},
[handleColorChange]
)
const handleSubmit = useCallback(() => {
onSubmit(currColor)
}, [onSubmit, currColor])
const renderModalHeading = () => {
switch (variant) {
case 'button':
return <Heading level="h2">Select a Button Color</Heading>
case 'background':
return <Heading level="h2">Select a Background Color</Heading>
}
}
return (
<Modal open={open} onDismiss={onClose} label="Link" size="small">
<Modal.Header>
{renderModalHeading()}
<CloseButton placement="end" onClick={onClose} screenReaderLabel="Close" />
</Modal.Header>
<Modal.Body padding="medium">
{variant === 'button' && (
<View as="div" margin="0 0 small 0" borderWidth="0 0 small 0" padding="0 0 small 0">
<FormFieldGroup description="Standard Button Colors">
<Flex as="div" margin="small" gap="small" wrap="wrap">
<RadioInput
value="primary"
label="Primary"
inline={true}
onChange={handleButtonColorChange}
checked={currColor === 'primary'}
/>
<RadioInput
value="secondary"
label="Secondary"
inline={true}
onChange={handleButtonColorChange}
checked={currColor === 'secondary'}
/>
<RadioInput
value="success"
label="Success"
inline={true}
onChange={handleButtonColorChange}
checked={currColor === 'success'}
/>
<RadioInput
value="danger"
label="Danger"
inline={true}
onChange={handleButtonColorChange}
checked={currColor === 'danger'}
/>
<RadioInput
value="primary-inverse"
label="Primary Inverse"
inline={true}
onChange={handleButtonColorChange}
checked={currColor === 'primary-inverse'}
/>
<RadioInput
value="custom"
label="Custom"
inline={true}
onChange={handleButtonColorChange}
checked={!isInstuiButtonColor(currColor)}
/>
</Flex>
</FormFieldGroup>
</View>
)}
<ColorPicker
label="Enter a hex color value"
onChange={handleColorChange}
value={currColor}
disabled={isInstuiButtonColor(currColor)}
/>
</Modal.Body>
<Modal.Footer>
<Button color="secondary" onClick={onClose}>
Cancel
</Button>
<Button color="primary" onClick={handleSubmit} margin="0 0 0 small">
Set Color
</Button>
</Modal.Footer>
</Modal>
)
}
export {ColorModal}

View File

@ -0,0 +1,78 @@
/*
* 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 React, {useCallback, useState} from 'react'
import {useNode} from '@craftjs/core'
import {IconButton} from '@instructure/ui-buttons'
import {Flex} from '@instructure/ui-flex'
import {IconBackgroundColor} from '../../../assets/internal-icons'
import {ColorModal} from './ColorModal'
import {type ContainerProps} from '../blocks/Container/types'
const SectionToolbar = () => {
const {
actions: {setProp},
props,
} = useNode(node => ({
props: node.data.props,
}))
const [colorModalOpen, setColorModalOpen] = useState(false)
const handleBackgroundColorChange = useCallback(
(color: string) => {
setProp((prps: ContainerProps) => (prps.background = color))
setColorModalOpen(false)
},
[setProp]
)
const handleBackgroundColorButtonClick = useCallback(() => {
setColorModalOpen(true)
}, [])
const handleCloseColorModal = useCallback(() => {
setColorModalOpen(false)
}, [])
return (
<Flex gap="small">
<IconButton
size="small"
withBackground={false}
withBorder={false}
screenReaderLabel="Color"
disabled={props.variant === 'condensed'}
onClick={handleBackgroundColorButtonClick}
>
<IconBackgroundColor size="x-small" />
</IconButton>
<ColorModal
open={colorModalOpen}
color={props.background}
variant="background"
onClose={handleCloseColorModal}
onSubmit={handleBackgroundColorChange}
/>
</Flex>
)
}
export {SectionToolbar}

View File

@ -25,6 +25,7 @@ import {ImageBlock} from '../../blocks/ImageBlock'
import {NoSections} from '../../common'
import {useClassNames, getContrastingColor} from '../../../../utils'
import {SectionMenu} from '../../../editor/SectionMenu'
import {SectionToolbar} from '../../common/SectionToolbar'
type AboutSectionProps = {
background?: string
@ -62,7 +63,6 @@ export const AboutSection = ({background}: AboutSectionProps) => {
<Element
id={`${cid}_image`}
is={ImageBlock}
canvas={true}
constraint="contain"
src="/images/block_editor/default_about_image.png"
/>
@ -73,7 +73,7 @@ export const AboutSection = ({background}: AboutSectionProps) => {
canvas={true}
className="about-section__inner-start"
>
<Element id={`${cid}_text`} is={AboutTextHalf} canvas={true} color={textColor} />
<Element id={`${cid}_text`} is={AboutTextHalf} color={textColor} />
</Element>
</Container>
)
@ -89,5 +89,6 @@ AboutSection.craft = {
},
related: {
sectionMenu: SectionMenu,
toolbar: SectionToolbar,
},
}

View File

@ -52,7 +52,6 @@ const FooterSection = ({background}: FooterSectionProps) => {
<Element
id={`${cid}__footer-canvas-icon`}
is={ImageBlock}
canvas={true}
src="/images/block_editor/canvas_logo_white.svg"
width={113}
height={28}
@ -60,7 +59,6 @@ const FooterSection = ({background}: FooterSectionProps) => {
<Element
id={`${cid}__footer-canvas-to-top`}
is={ButtonBlock}
canvas={true}
text="Back to top"
variant="condensed"
color={buttonColor}

File diff suppressed because one or more lines are too long

View File

@ -24,6 +24,7 @@ import {ImageBlock} from '../../blocks/ImageBlock'
import {NoSections} from '../../common'
import {useClassNames, getContrastingColor} from '../../../../utils'
import {SectionMenu} from '../../../editor/SectionMenu'
import {SectionToolbar} from '../../common/SectionToolbar'
type HeroSectionProps = {
background?: string
@ -52,24 +53,13 @@ export const HeroSection = ({background}: HeroSectionProps) => {
// what's already there.
return (
<Container className={clazz} background={backgroundColor}>
<Element
id={`${cid}_nosection1`}
is={NoSections}
canvas={true}
className="hero-section__inner-start"
>
<Element id={`${cid}_text`} is={HeroTextHalf} canvas={true} color={textColor} />
<Element id={`${cid}_nosection1`} is={NoSections} className="hero-section__inner-start">
<Element id={`${cid}_text`} is={HeroTextHalf} color={textColor} />
</Element>
<Element
id={`${cid}_nosection2`}
is={NoSections}
canvas={true}
className="hero-section__inner-end"
>
<Element id={`${cid}_nosection2`} is={NoSections} className="hero-section__inner-end">
<Element
id={`${cid}_image`}
is={ImageBlock}
canvas={true}
constraint="contain"
src="/images/block_editor/default_hero_image.png"
/>
@ -88,5 +78,6 @@ HeroSection.craft = {
},
related: {
sectionMenu: SectionMenu,
toolbar: SectionToolbar,
},
}

View File

@ -54,6 +54,7 @@ const HeroTextHalf = ({
text={title}
level="h2"
custom={{
displayName: 'Headline',
themeOverride: {
h2FontFamily:
'Georgia, LatoWeb, Lato, "Helvetica Neue", Helvetica, Arial, sans-serif',
@ -63,7 +64,14 @@ const HeroTextHalf = ({
},
}}
/>
<Element id={`${id}__text`} is={TextBlock} text={text} textAlign="start" color={color} />
<Element
id={`${id}__text`}
is={TextBlock}
text={text}
textAlign="start"
color={color}
custom={{displayName: 'Details'}}
/>
<Element
id={`${id}__link`}
is={ButtonBlock}

View File

@ -22,6 +22,7 @@ import {Container} from '../../blocks/Container'
import {ButtonBlock} from '../../blocks/ButtonBlock'
import {useClassNames, getContrastingColor} from '../../../../utils'
import {SectionMenu} from '../../../editor/SectionMenu'
import {SectionToolbar} from '../../common/SectionToolbar'
export type NavigationSectionInnerProps = {
children?: React.ReactNode
@ -125,6 +126,7 @@ NavigationSection.craft = {
},
related: {
sectionMenu: SectionMenu,
toolbar: SectionToolbar,
},
}

View File

@ -16,8 +16,14 @@
}
.toolbox-item:hover {
outline: 1px solid var(--ic-brand-primary);
.toolbox-item {
cursor: grab;
&:hover {
outline: 1px solid var(--ic-brand-primary);
}
&:active {
cursor: grabbing;
}
}
.font0 {font-family: lato, 'Helvetica Neue', Helvetica, Arial, sans-serif;}
@ -54,7 +60,7 @@
border-style: solid solid solid none;
}
.block-editor-editor .block-toolbar {
.block-editor-editor .block-toolbar, .block-editor-editor .block-tag {
position: fixed;
display: flex;
gap: 0.25rem;

View File

@ -17,6 +17,7 @@
*/
// code copied from https://github.com/prevwong/craft.js/issues/209#issuecomment-795221484
// but with a couple modifications
import {type Node} from '@craftjs/core'
import {uid} from '@instructure/uid'
@ -26,13 +27,13 @@ export const getCloneTree = (idToClone: string, query: any) => {
const newNodes: Record<string, Node> = {}
const changeNodeId = (node: Node, newParentId?: string) => {
let newNodeId = uid('node', 2)
const newNodeId = uid('node', 2)
const childNodes = node.data.nodes.map(childId => changeNodeId(tree.nodes[childId], newNodeId))
const linkedNodes = Object.keys(node.data.linkedNodes).reduce((accum, id) => {
newNodeId = changeNodeId(tree.nodes[node.data.linkedNodes[id]], newNodeId)
const linkedNodeId = changeNodeId(tree.nodes[node.data.linkedNodes[id]], newNodeId)
return {
...accum,
[id]: newNodeId,
[id]: linkedNodeId,
}
}, {})

View File

@ -16,14 +16,19 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export function getScrollParent(node: Element | null) {
if (node == null) {
return null
}
// export function getScrollParent(node: Element | null) {
// if (node == null) {
// return null
// }
if (node.scrollHeight > node.clientHeight) {
return node
} else {
return getScrollParent(node.parentNode as Element | null)
}
// if (node.scrollHeight > node.clientHeight) {
// return node
// } else {
// return getScrollParent(node.parentNode as Element | null)
// }
// }
export function getScrollParent() {
// depends on whether new user tutorial is enabled or not
return document.getElementById('drawer-layout-content') || document.documentElement
}

View File

@ -233,7 +233,11 @@ export default class WikiPageEditView extends ValidatedFormView {
}
ReactDOM.render(
<Suspense fallback={<div>{I18n.t('Loading...')}</div>}>
<BlockEditor version={blockEditorData.version} content={blockEditorData.blocks[0].data} />
<BlockEditor
version={blockEditorData.version}
content={blockEditorData.blocks[0].data}
onCancel={this.cancel.bind(this)}
/>
</Suspense>,
document.getElementById('block_editor')
)

View File

@ -1312,10 +1312,10 @@
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==
"@craftjs/core@^0.2.7":
version "0.2.7"
resolved "https://registry.yarnpkg.com/@craftjs/core/-/core-0.2.7.tgz#1d2ceed72ca2441ff211fe33ce44e051d71adb93"
integrity sha512-tD0m2OwnG9n3S6OPmPq6ZXjFuTvr/zthgpoyGIyKqtX1hVVh4I/6mKKw8zghJEU57mrETlOpwXLoWoL40c8iHA==
"@craftjs/core@^0.2.8":
version "0.2.8"
resolved "https://registry.yarnpkg.com/@craftjs/core/-/core-0.2.8.tgz#d16bb0b5dd84417d631bf181adc491db7048307f"
integrity sha512-gVZgBWyhJBD3TYdFT60yZcJWhmLHx7SnAU+EjnXGMbOeU4EDr9iTnCIBhYAovGArG22UPo/sdTJqrE2MFR7Yjg==
dependencies:
"@craftjs/utils" "^0.2.2"
debounce "^1.2.0"