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
4 changes: 4 additions & 0 deletions packages/app-core/src/components/VimNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,10 @@ export function VimNav(): JSX.Element | null {
// the open menu. (#337)
const fmtView = state.editorViewRef
if (fmtView && isEditorFocused(fmtView) && completionStatus(fmtView.state) !== 'active') {
// Table cells have their own formatting handler; don't apply the
// editor-level toggle to the main selection while focus is inside a cell.
if (document.activeElement?.closest('.cm-table-widget')) return

// Focus the selection toolbar (when shown) for keyboard navigation.
if (matchesShortcutBinding(e, 'Mod+/')) {
const firstItem = document.querySelector<HTMLElement>(
Expand Down
180 changes: 116 additions & 64 deletions packages/app-core/src/lib/cm-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ function longerMarkerPairAt(state: EditorState, at: number, marker: string): boo
return WRAP_MARKERS.some((w) => w.length > marker.length && isEmptyPairAt(state, at, w))
}

function isEmptyPairAtText(text: string, at: number, marker: string): boolean {
if (at - marker.length < 0 || at + marker.length > text.length) return false
return (
text.slice(at - marker.length, at) === marker &&
text.slice(at, at + marker.length) === marker
)
}

function longerMarkerPairAtText(text: string, at: number, marker: string): boolean {
return WRAP_MARKERS.some((w) => w.length > marker.length && isEmptyPairAtText(text, at, w))
}

/**
* `text` (the line up to the cursor) leaves `marker` open — an odd number of
* them, so the cursor is inside a span this marker started. A single `*` skips
Expand Down Expand Up @@ -80,81 +92,123 @@ export function formatMarkerBackspaceTransaction(state: EditorState): Transactio
}

/**
* Toggle a symmetric inline marker around each selection range: wrap when it
* isn't wrapped, unwrap when the markers already sit just outside (or just
* inside) the selection.
* Pure-text version of the symmetric-marker toggle. Returns a single change
* range and the selection that should be active after applying it.
*/
export function toggleWrap(view: EditorView, marker: string): boolean {
export function toggleWrapEdit(
text: string,
marker: string,
from: number,
to: number,
lineStart = 0
): { from: number; to: number; insert: string; selection: { from: number; to: number } } {
const m = marker
view.dispatch(
view.state.changeByRange((range) => {
const { from, to } = range
if (from === to) {
const before = view.state.sliceDoc(Math.max(0, from - m.length), from)
const after = view.state.sliceDoc(from, Math.min(view.state.doc.length, from + m.length))

if (after === m) {
if (before === m && !longerMarkerPairAt(view.state, from, m)) {
// Empty pair: pressing the shortcut again removes the markers.
return {
changes: { from: from - m.length, to: from + m.length, insert: '' },
range: EditorSelection.cursor(from - m.length)
}
}

const line = view.state.doc.lineAt(from)
const lineBefore = view.state.sliceDoc(line.from, from)
if (isInsideUnclosedMarker(lineBefore, m)) {
// Cursor is just before the closing marker from a previously inserted
// pair. Treat the shortcut as leaving/toggling off formatting instead
// of inserting another marker pair inside it.
return {
changes: [],
range: EditorSelection.cursor(from + m.length)
}
}
}
if (from === to) {
const before = text.slice(Math.max(0, from - m.length), from)
const after = text.slice(from, Math.min(text.length, from + m.length))

// No selection: insert the pair and drop the cursor between them.
return {
changes: { from, insert: m + m },
range: EditorSelection.cursor(from + m.length)
}
}
const before = view.state.sliceDoc(Math.max(0, from - m.length), from)
const after = view.state.sliceDoc(to, Math.min(view.state.doc.length, to + m.length))
if (before === m && after === m) {
// Unwrap: drop the markers just outside the selection.
return {
changes: [
{ from: from - m.length, to: from, insert: '' },
{ from: to, to: to + m.length, insert: '' }
],
range: EditorSelection.range(from - m.length, to - m.length)
}
if (after === m && before === m && !longerMarkerPairAtText(text, from, m)) {
// Empty pair: pressing the shortcut again removes the markers.
return {
from: from - m.length,
to: from + m.length,
insert: '',
selection: { from: from - m.length, to: from - m.length }
}
const selected = view.state.sliceDoc(from, to)
if (selected.length >= m.length * 2 && selected.startsWith(m) && selected.endsWith(m)) {
// The selection itself includes the markers — strip them from inside.
}

if (after === m) {
const lineBefore = text.slice(lineStart, from)
if (isInsideUnclosedMarker(lineBefore, m)) {
// Cursor is just before the closing marker from a previously inserted
// pair. Leave the span instead of inserting another marker pair.
return {
changes: { from, to, insert: selected.slice(m.length, selected.length - m.length) },
range: EditorSelection.range(from, to - m.length * 2)
from,
to,
insert: '',
selection: { from: from + m.length, to: from + m.length }
}
}
// Wrap.
}

// No selection: insert the pair and drop the cursor between them.
return {
from,
to,
insert: m + m,
selection: { from: from + m.length, to: from + m.length }
}
}

const before = text.slice(Math.max(0, from - m.length), from)
const after = text.slice(to, Math.min(text.length, to + m.length))
if (before === m && after === m) {
// Unwrap: drop the markers just outside the selection.
return {
from: from - m.length,
to: to + m.length,
insert: text.slice(from, to),
selection: { from: from - m.length, to: to - m.length }
}
}
const selected = text.slice(from, to)
if (selected.length >= m.length * 2 && selected.startsWith(m) && selected.endsWith(m)) {
// The selection itself includes the markers — strip them from inside.
return {
from,
to,
insert: selected.slice(m.length, selected.length - m.length),
selection: { from, to: to - m.length * 2 }
}
}
// Wrap.
return {
from,
to,
insert: m + selected + m,
selection: { from: from + m.length, to: to + m.length }
}
}

/**
* Toggle a symmetric inline marker around each selection range: wrap when it
* isn't wrapped, unwrap when the markers already sit just outside (or just
* inside) the selection.
*/
export function toggleWrap(view: EditorView, marker: string): boolean {
const text = view.state.doc.toString()
view.dispatch(
view.state.changeByRange((range) => {
const lineStart = view.state.doc.lineAt(range.from).from
const edit = toggleWrapEdit(text, marker, range.from, range.to, lineStart)
return {
changes: [
{ from, insert: m },
{ from: to, insert: m }
],
range: EditorSelection.range(from + m.length, to + m.length)
changes: { from: edit.from, to: edit.to, insert: edit.insert },
range: EditorSelection.range(edit.selection.from, edit.selection.to)
}
})
)
view.focus()
return true
}

/**
* Pure-text version of link wrapping. Returns a single change range and the
* cursor position after the opening parenthesis.
*/
export function wrapLinkEdit(
selected: string,
from: number,
to: number
): { from: number; to: number; insert: string; cursor: number } {
const insert = `[${selected}]()`
return {
from,
to,
insert,
cursor: from + insert.length - 1
}
}

/**
* The block types offered by the selection toolbar's "Turn into" menu — a
* lighter version of Notion's block menu.
Expand Down Expand Up @@ -242,11 +296,9 @@ export function wrapLink(view: EditorView): boolean {
view.dispatch(
view.state.changeByRange((range) => {
const { from, to } = range
const text = view.state.sliceDoc(from, to)
const insert = `[${text}]()`
const edit = wrapLinkEdit(view.state.sliceDoc(from, to), from, to)
// Cursor between the parentheses: after `[text](`.
const cursor = from + 1 + text.length + 2
return { changes: { from, to, insert }, range: EditorSelection.cursor(cursor) }
return { changes: { from: edit.from, to: edit.to, insert: edit.insert }, range: EditorSelection.cursor(edit.cursor) }
})
)
view.focus()
Expand Down
Loading