Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions packages/editor/gherkin-spec/per-style-restrictions.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
Feature: Per-style schema restrictions

Scenario: Decorator toggle is blocked on restricted style
Given the text "hello world"
When "hello world" is selected
And "h1" is toggled
And "strong" is toggled
Then "hello world" has no marks

Scenario: Decorator toggle works on unrestricted style
Given the text "hello world"
When "hello world" is selected
And "strong" is toggled
Then "hello world" has marks "strong"

Scenario: Style change to restricted strips existing decorators
Given the text "hello world"
When "hello world" is selected
And "strong" is toggled
Then "hello world" has marks "strong"
When "h1" is toggled
Then "hello world" has no marks

Scenario: Style change to unrestricted preserves decorators
Given the text "hello world"
When "hello world" is selected
And "strong" is toggled
Then "hello world" has marks "strong"
When "blockquote" is toggled
Then "hello world" has marks "strong"

Scenario: Partially restricted style allows some decorators
Given the text "hello world"
When "hello world" is selected
And "h2" is toggled
And "em" is toggled
Then "hello world" has marks "em"

Scenario: Partially restricted style blocks disallowed decorators
Given the text "hello world"
When "hello world" is selected
And "h2" is toggled
And "strong" is toggled
Then "hello world" has no marks

Scenario: Annotation toggle is blocked on restricted style
Given the text "hello world"
When "hello world" is selected
And "h1" is toggled
And "link" is toggled
Then "hello world" has no marks

Scenario: Annotation toggle works on unrestricted style
Given the text "hello world"
When "hello world" is selected
And "link" "a1" is toggled
Then "hello world" has marks "a1"

Scenario: Style change to restricted strips existing annotations
Given the text "hello world"
When "hello world" is selected
And "link" "a1" is toggled
Then "hello world" has marks "a1"
When "h1" is toggled
Then "hello world" has no marks

Scenario: List toggle is blocked on restricted style
Given the text "hello world"
When "hello world" is selected
And "h1" is toggled
And "bullet" list is toggled
Then the text is "h1:hello world"

Scenario: List toggle works on unrestricted style
Given the text "hello world"
When "hello world" is selected
And "bullet" list is toggled
Then the text is ">-:hello world"
34 changes: 34 additions & 0 deletions packages/editor/gherkin-tests/per-style-restrictions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {defineSchema} from '@portabletext/schema'
import {Before} from 'racejar'
import {Feature} from 'racejar/vitest'
import perStyleRestrictionsFeature from '../gherkin-spec/per-style-restrictions.feature?raw'
import {parameterTypes} from '../src/test'
import {createTestEditor, stepDefinitions} from '../src/test/vitest'
import type {Context} from '../src/test/vitest/step-context'

Feature({
hooks: [
Before(async (context: Context) => {
const {editor, locator} = await createTestEditor({
schemaDefinition: defineSchema({
decorators: [{name: 'strong'}, {name: 'em'}],
annotations: [{name: 'link'}],
styles: [
{name: 'normal'},
{name: 'h1', decorators: [], annotations: [], lists: []},
{name: 'h2', decorators: [{name: 'em'}]},
{name: 'blockquote'},
],
lists: [{name: 'bullet'}],
}),
})

context.keyMap = new Map()
context.locator = locator
context.editor = editor
}),
],
featureText: perStyleRestrictionsFeature,
stepDefinitions,
parameterTypes,
})
13 changes: 12 additions & 1 deletion packages/editor/src/behaviors/behavior.abstract.annotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {getFocusTextBlock} from '../selectors/selector.get-focus-text-block'
import {isActiveAnnotation} from '../selectors/selector.is-active-annotation'
import {raise} from './behavior.types.action'
import {defineBehavior} from './behavior.types.behavior'
import {isAnnotationAllowedByStyle} from './behavior.utils.style-feature-allowed'

export const abstractAnnotationBehaviors = [
defineBehavior({
Expand Down Expand Up @@ -100,7 +101,17 @@ export const abstractAnnotationBehaviors = [
},
}

return !isActiveAnnotation(event.annotation.name)(adjustedSnapshot)
if (isActiveAnnotation(event.annotation.name)(adjustedSnapshot)) {
return false
}

if (
!isAnnotationAllowedByStyle(adjustedSnapshot, event.annotation.name)
) {
return false
}

return true
},
actions: [
({event}) => [
Expand Down
5 changes: 5 additions & 0 deletions packages/editor/src/behaviors/behavior.abstract.decorator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {isActiveDecorator} from '../selectors/selector.is-active-decorator'
import {raise} from './behavior.types.action'
import {defineBehavior} from './behavior.types.behavior'
import {isDecoratorAllowedByStyle} from './behavior.utils.style-feature-allowed'

export const abstractDecoratorBehaviors = [
defineBehavior({
Expand Down Expand Up @@ -41,6 +42,10 @@ export const abstractDecoratorBehaviors = [
return false
}

if (!isDecoratorAllowedByStyle(snapshot, event.decorator)) {
return false
}

const adjustedSnapshot = {
...snapshot,
context: {
Expand Down
5 changes: 5 additions & 0 deletions packages/editor/src/behaviors/behavior.abstract.list-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {getSelectedTextBlocks} from '../selectors/selector.get-selected-text-blo
import {isActiveListItem} from '../selectors/selector.is-active-list-item'
import {raise} from './behavior.types.action'
import {defineBehavior} from './behavior.types.behavior'
import {isListAllowedByStyle} from './behavior.utils.style-feature-allowed'

export const abstractListItemBehaviors = [
defineBehavior({
Expand All @@ -15,6 +16,10 @@ export const abstractListItemBehaviors = [
return false
}

if (!isListAllowedByStyle(snapshot, event.listItem)) {
return false
}

const selectedTextBlocks = getSelectedTextBlocks(snapshot)

return {selectedTextBlocks}
Expand Down
36 changes: 24 additions & 12 deletions packages/editor/src/behaviors/behavior.core.decorators.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,30 @@
import {defaultKeyboardShortcuts} from '../editor/default-keyboard-shortcuts'
import type {EditorSnapshot} from '../editor/editor-snapshot'
import {getFocusTextBlock} from '../selectors/selector.get-focus-text-block'
import {raise} from './behavior.types.action'
import {defineBehavior} from './behavior.types.behavior'
import {isDecoratorAllowedByStyle} from './behavior.utils.style-feature-allowed'

export const coreDecoratorBehaviors = {
strongShortcut: defineBehavior({
on: 'keyboard.keydown',
guard: ({snapshot, event}) =>
defaultKeyboardShortcuts.decorators.strong.guard(event.originEvent) &&
snapshot.context.schema.decorators.some(
(decorator) => decorator.name === 'strong',
),
isDecoratorShortcutAllowed(snapshot, 'strong'),
actions: [() => [raise({type: 'decorator.toggle', decorator: 'strong'})]],
}),
emShortcut: defineBehavior({
on: 'keyboard.keydown',
guard: ({snapshot, event}) =>
defaultKeyboardShortcuts.decorators.em.guard(event.originEvent) &&
snapshot.context.schema.decorators.some(
(decorator) => decorator.name === 'em',
),
isDecoratorShortcutAllowed(snapshot, 'em'),
actions: [() => [raise({type: 'decorator.toggle', decorator: 'em'})]],
}),
underlineShortcut: defineBehavior({
on: 'keyboard.keydown',
guard: ({snapshot, event}) =>
defaultKeyboardShortcuts.decorators.underline.guard(event.originEvent) &&
snapshot.context.schema.decorators.some(
(decorator) => decorator.name === 'underline',
),
isDecoratorShortcutAllowed(snapshot, 'underline'),
actions: [
() => [raise({type: 'decorator.toggle', decorator: 'underline'})],
],
Expand All @@ -36,9 +33,24 @@ export const coreDecoratorBehaviors = {
on: 'keyboard.keydown',
guard: ({snapshot, event}) =>
defaultKeyboardShortcuts.decorators.code.guard(event.originEvent) &&
snapshot.context.schema.decorators.some(
(decorator) => decorator.name === 'code',
),
isDecoratorShortcutAllowed(snapshot, 'code'),
actions: [() => [raise({type: 'decorator.toggle', decorator: 'code'})]],
}),
}

function isDecoratorShortcutAllowed(
snapshot: EditorSnapshot,
decoratorName: string,
): boolean {
if (
!snapshot.context.schema.decorators.some(
(decorator) => decorator.name === decoratorName,
)
) {
return false
}
if (!getFocusTextBlock(snapshot)) {
return false
}
return isDecoratorAllowedByStyle(snapshot, decoratorName)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type {EditorSnapshot} from '../editor/editor-snapshot'
import {getFocusTextBlock} from '../selectors/selector.get-focus-text-block'

export function isDecoratorAllowedByStyle(
snapshot: EditorSnapshot,
decoratorName: string,
): boolean {
return isFeatureAllowedByStyle(snapshot, 'decorators', decoratorName)
}

export function isAnnotationAllowedByStyle(
snapshot: EditorSnapshot,
annotationName: string,
): boolean {
return isFeatureAllowedByStyle(snapshot, 'annotations', annotationName)
}

export function isListAllowedByStyle(
snapshot: EditorSnapshot,
listName: string,
): boolean {
return isFeatureAllowedByStyle(snapshot, 'lists', listName)
}

function isFeatureAllowedByStyle(
snapshot: EditorSnapshot,
feature: 'decorators' | 'annotations' | 'lists' | 'inlineObjects',
name: string,
): boolean {
const focusTextBlock = getFocusTextBlock(snapshot)
if (!focusTextBlock) {
return true
}
const style = focusTextBlock.node.style
const styleType = style
? snapshot.context.schema.styles.find((s) => s.name === style)
: undefined
const allowed = styleType?.[feature]
if (allowed && !allowed.some((item) => item.name === name)) {
return false
}
return true
}
14 changes: 13 additions & 1 deletion packages/editor/src/paths/get-dirty-indexed-paths.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {isSpan} from '@portabletext/schema'
import {isSpan, isTextBlock} from '@portabletext/schema'
import type {EditorSchema} from '../editor/editor-schema'
import {getNodeDescendants} from '../node-traversal/get-nodes'
import type {Node} from '../slate/interfaces/node'
Expand Down Expand Up @@ -52,6 +52,18 @@ export function getDirtyIndexedPaths(
}
}
}

// When the `style` property changes on a text block, dirty all
// child paths so per-style restriction normalization can strip
// marks that the new style disallows.
if ('style' in op.newProperties) {
const block = context.value[path[0]!]
if (block && isTextBlock(context, block)) {
for (let i = 0; i < block.children.length; i++) {
levels.push([...path, i])
}
}
}
}

return levels
Expand Down
53 changes: 53 additions & 0 deletions packages/editor/src/slate/core/normalize-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,59 @@ export const normalizeNode: WithEditorFirstArg<Editor['normalizeNode']> = (
}
}

/**
* Remove marks not allowed by the block's per-style restrictions
*/
if (isSpan({schema: editor.schema}, node)) {
const blockPath = parentPath(path)
const blockEntry = getTextBlockNode(editor, blockPath)
if (!blockEntry) {
return
}
const style = blockEntry.node.style
const styleType = style
? editor.schema.styles.find((s) => s.name === style)
: undefined

if (styleType) {
const allowedDecorators = styleType.decorators
const allowedAnnotations = styleType.annotations

if (allowedDecorators || allowedAnnotations) {
const decoratorNames = editor.schema.decorators.map((d) => d.name)
const allowedDecoratorNames = allowedDecorators?.map((d) => d.name)
const allowedAnnotationNames = allowedAnnotations?.map((a) => a.name)

const marks = node.marks ?? []
const filteredMarks = marks.filter((mark) => {
const isDecorator = decoratorNames.includes(mark)
if (isDecorator) {
return allowedDecoratorNames
? allowedDecoratorNames.includes(mark)
: true
}
if (allowedAnnotationNames) {
const markDef = blockEntry.node.markDefs?.find(
(def) => def._key === mark,
)
return markDef
? allowedAnnotationNames.includes(markDef._type)
: true
}
return true
})

if (filteredMarks.length !== marks.length) {
debug.normalization(
'removing marks not allowed by style restrictions',
)
applySetNode(editor, {marks: filteredMarks}, path)
return
}
}
}
}

/**
* Remove orphaned annotations from child spans of block nodes
*/
Expand Down
5 changes: 5 additions & 0 deletions packages/editor/src/test/gherkin-parameter-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ const parameterType = {
name: 'inline-object',
matcher: /"(stock-ticker)"/,
}),
listItem: createParameterType<'bullet' | 'number'>({
name: 'list-item',
matcher: /"(bullet|number)"/,
}),
key: createParameterType<'key'>({
name: 'key',
matcher: /"([a-z]\d)"/,
Expand Down Expand Up @@ -106,6 +110,7 @@ export const parameterTypes = [
parameterType.direction,
parameterType.index,
parameterType.inlineObject,
parameterType.listItem,
parameterType.key,
parameterType.keyKeys,
parameterType.marks,
Expand Down
Loading
Loading