Skip to content

Commit

Permalink
feat: underline, hidden, colour text formatting
Browse files Browse the repository at this point in the history
  • Loading branch information
mint-dewit committed Feb 22, 2024
1 parent d4495c2 commit 42496ac
Show file tree
Hide file tree
Showing 11 changed files with 372 additions and 12 deletions.
7 changes: 7 additions & 0 deletions packages/apps/client/src/PrompterStyles.css
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,10 @@
.Prompter .ProseMirror {
outline: none;
}

.Prompter span.colour.red {
color: red
}
.Prompter span.colour.yellow {
color: yellow
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,14 @@ function MdNode({ content }: { content: Node }): React.ReactNode {
return <b>{renderChildren(content)}</b>
case 'reverse':
return React.createElement('rev', {}, renderChildren(content))
case 'underline':
return React.createElement('u', {}, renderChildren(content))
case 'colour':
return React.createElement('span', { className: 'colour ' + content.colour }, renderChildren(content))
case 'text':
return content.value
case 'hidden':
return null
default:
assertNever(content)
return null
Expand Down
105 changes: 103 additions & 2 deletions packages/apps/client/src/components/ScriptEditor/keymaps.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,112 @@
import { Command } from 'prosemirror-state'
import { Command, SelectionRange, TextSelection } from 'prosemirror-state'
import { toggleMark } from 'prosemirror-commands'
import { schema } from './scriptSchema'
import { MarkType, Node } from 'prosemirror-model'

export const formatingKeymap: Record<string, Command> = {
'Mod-b': toggleMark(schema.marks.bold),
'Mod-i': toggleMark(schema.marks.italic),
'Mod-u': toggleMark(schema.marks.underline),
'Mod-q': toggleMark(schema.marks.reverse),
'Mod-r': toggleMark(schema.marks.reverse),
'Mod-f': toggleColours(schema.marks.colour, ['red', 'yellow']),
'Mod-F10': toggleMark(schema.marks.hidden),
}

/**
* This toggles the colour marks between colours, taken
* from https://github.com/ProseMirror/prosemirror-commands/blob/master/src/commands.ts#L583
*/
function toggleColours(
markType: MarkType,
colours: string[],
options?: {
/**
* Controls whether, when part of the selected range has the mark
* already and part doesn't, the mark is removed (`true`, the
* default) or added (`false`).
*/
removeWhenPresent: boolean
}
): Command {
const removeWhenPresent = (options && options.removeWhenPresent) !== false
return function (state, dispatch) {
const { empty, $cursor, ranges } = state.selection as TextSelection
if (!dispatch || (empty && !$cursor) || colours.length === 0 || !markApplies(state.doc, ranges, markType))
return false

if ($cursor) {
if (markType.isInSet(state.storedMarks || $cursor.marks())) dispatch(state.tr.removeStoredMark(markType))
else dispatch(state.tr.addStoredMark(markType.create({ colour: colours[0] })))
} else {
let add
const tr = state.tr
if (removeWhenPresent) {
add = !ranges.some((r) => state.doc.rangeHasMark(r.$from.pos, r.$to.pos, markType))
} else {
add = !ranges.every((r) => {
let missing = false
tr.doc.nodesBetween(r.$from.pos, r.$to.pos, (node, pos, parent) => {
if (missing) return
missing =
!markType.isInSet(node.marks) &&
!!parent &&
parent.type.allowsMarkType(markType) &&
!(
node.isText &&
/^\s*$/.test(node.textBetween(Math.max(0, r.$from.pos - pos), Math.min(node.nodeSize, r.$to.pos - pos)))
)
})
return !missing
})
}
for (let i = 0; i < ranges.length; i++) {
const { $from, $to } = ranges[i]

let from = $from.pos,
to = $to.pos
const start = $from.nodeAfter,
end = $to.nodeBefore
const spaceStart = start && start.isText ? /^\s*/.exec(start.text!)![0].length : 0
const spaceEnd = end && end.isText ? /\s*$/.exec(end.text!)![0].length : 0
if (from + spaceStart < to) {
from += spaceStart
to -= spaceEnd
}

if (!add) {
// this is where we may want to toggle...
let next
// see if the beginning has the mark
const mark = markType.isInSet($to.marks())

if (mark) {
// try to find next colour
const current = mark.attrs.colour
const pos = colours.findIndex((c) => c === current)
next = colours[pos + 1]
}

tr.removeMark($from.pos, $to.pos, markType)
if (next) tr.addMark(from, to, markType.create({ colour: next }))
} else {
tr.addMark(from, to, markType.create({ colour: colours[0] }))
}
}
dispatch(tr.scrollIntoView())
}

return true
}
}
function markApplies(doc: Node, ranges: readonly SelectionRange[], type: MarkType) {
for (let i = 0; i < ranges.length; i++) {
const { $from, $to } = ranges[i]
let can = $from.depth == 0 ? doc.inlineContent && doc.type.allowsMarkType(type) : false
doc.nodesBetween($from.pos, $to.pos, (node: Node) => {
if (can) return
can = node.inlineContent && node.type.allowsMarkType(type)
})
if (can) return true
}
return false
}
14 changes: 12 additions & 2 deletions packages/apps/client/src/components/ScriptEditor/scriptSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,12 @@ export const schema = new Schema({
marks: {
bold: {
toDOM() {
return ['b', 0]
return ['strong', 0]
},
},
italic: {
toDOM() {
return ['i', 0]
return ['em', 0]
},
},
underline: {
Expand All @@ -112,5 +112,15 @@ export const schema = new Schema({
return ['rev', 0]
},
},
colour: {
attrs: {
colour: {},
},
toDOM(mark) {
const col = mark.attrs.colour

return ['span', { class: 'colour ' + col }]
},
},
},
})
27 changes: 26 additions & 1 deletion packages/apps/client/src/lib/mdParser/astNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,34 @@ export interface EmphasisNode extends ParentNodeBase {
code: string
}

export interface UnderlineNode extends ParentNodeBase {
type: 'underline'
code: string
}

export interface HiddenNode extends ParentNodeBase {
type: 'hidden'
code: string
}

export interface ReverseNode extends ParentNodeBase {
type: 'reverse'
code: string
}

export type Node = RootNode | ParagraphNode | TextNode | StrongNode | EmphasisNode | ReverseNode
export interface ColourNode extends ParentNodeBase {
type: 'colour'
code: string
colour: 'red' | 'yellow'
}

export type Node =
| RootNode
| ParagraphNode
| TextNode
| StrongNode
| EmphasisNode
| ReverseNode
| UnderlineNode
| HiddenNode
| ColourNode
60 changes: 60 additions & 0 deletions packages/apps/client/src/lib/mdParser/constructs/colour.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { ColourNode } from '../astNodes'
import { NodeConstruct, ParserState, CharHandlerResult } from '../parserState'

export function colour(): NodeConstruct {
function colour(char: string, state: ParserState): CharHandlerResult | void {
if (state.nodeCursor === null) throw new Error('cursor === null assertion')

/**
* support for:
* [colour=#ff0000][/colour] => red text
* [colour=#ffff00][/colour] => yellow text
*
* i.e. the colour tag uses a hex code but does not actually support hex codes.
* in the future we can support more colours easily, and the length of the tag is stable
* which means the parsing is a bit simpler.
*/

let rest = state.peek(15)
let end = false
if (rest?.startsWith('/')) {
end = true
rest = state.peek(8)?.slice(1)
}
if (!rest || (end ? rest.length < 7 : rest.length < 15)) return
if (!rest?.endsWith(']')) return
if (!rest.includes('colour')) return

if (end) {
if (state.nodeCursor.type === 'colour') {
for (let i = 0; i < 8; i++) state.consume()
state.flushBuffer()
state.popNode()
return CharHandlerResult.StopProcessingNoBuffer
}
} else {
for (let i = 0; i < 15; i++) state.consume()

state.flushBuffer()

const colour = rest.includes('#ffff00') ? 'yellow' : 'red'

const colourNode: ColourNode = {
type: 'colour',
children: [],
code: char,
colour: colour,
}
state.pushNode(colourNode)

return CharHandlerResult.StopProcessingNoBuffer
}
}

return {
name: 'colour',
char: {
'[': colour,
},
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,19 @@ import { EmphasisNode, StrongNode, Node, ParentNodeBase } from '../astNodes'
export function emphasisAndStrong(): NodeConstruct {
function emphasisOrStrong(char: string, state: ParserState): CharHandlerResult | void {
if (state.nodeCursor === null) throw new Error('cursor === null assertion')

let len = 1
let peeked = state.peek(len)
while (peeked && peeked.length === len && peeked.slice(-1) === char) {
len++
peeked = state.peek(len)
}

if (len > 2) return // this parser only handles 2 chars

if (state.nodeCursor && isEmphasisOrStrongNode(state.nodeCursor)) {
if (state.nodeCursor.code.startsWith(char)) {
if (state.peek() === char) {
if (state.nodeCursor.type === 'strong' && len === 2) {
state.consume()
}

Expand All @@ -20,7 +30,7 @@ export function emphasisAndStrong(): NodeConstruct {

let type: 'emphasis' | 'strong' = 'emphasis'

if (state.peek() === char) {
if (len === 2) {
type = 'strong'
char += state.consume()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { NodeConstruct, ParserState, CharHandlerResult } from '../parserState'
import { HiddenNode, UnderlineNode } from '../astNodes'

export function underlineOrHide(): NodeConstruct {
function underlineOrHide(char: string, state: ParserState): CharHandlerResult | void {
if (state.nodeCursor === null) throw new Error('cursor === null assertion')

let len = 1
let peeked = state.peek(len)
while (peeked && peeked.length === len && peeked.slice(-1) === char) {
len++
peeked = state.peek(len)
}

switch (len) {
case 2:
return underline(char, state)
case 1:
return hide(char, state)
default:
return
}
}

return {
name: 'underline',
char: {
'|': underlineOrHide,
$: underlineOrHide,
},
}
}

function hide(char: string, state: ParserState): CharHandlerResult | void {
if (state.nodeCursor === null) throw new Error('cursor === null assertion')

// consume once
// char += state.consume()

if (state.nodeCursor.type === 'hidden' && 'code' in state.nodeCursor && state.nodeCursor.code === char) {
state.flushBuffer()
state.popNode()
return CharHandlerResult.StopProcessingNoBuffer
}

state.flushBuffer()

const type = 'hidden'

const underlineNode: HiddenNode = {
type,
children: [],
code: char,
}
state.pushNode(underlineNode)

return CharHandlerResult.StopProcessingNoBuffer
}

function underline(char: string, state: ParserState): CharHandlerResult | void {
if (state.nodeCursor === null) throw new Error('cursor === null assertion')

// consume twice
char += state.consume()
// char += state.consume()

if (state.nodeCursor.type === 'underline' && 'code' in state.nodeCursor && state.nodeCursor.code === char) {
state.flushBuffer()
state.popNode()
return CharHandlerResult.StopProcessingNoBuffer
}

state.flushBuffer()

const type = 'underline'

const underlineNode: UnderlineNode = {
type,
children: [],
code: char,
}
state.pushNode(underlineNode)

return CharHandlerResult.StopProcessingNoBuffer
}
Loading

0 comments on commit 42496ac

Please sign in to comment.