2024-01-24 12:34:12 +00:00
import { commandLinkRegex } from "../common/markdown_parser/parser.ts" ;
2023-07-14 11:58:16 +00:00
import { readonlyMode } from "./cm_plugins/readonly.ts" ;
import customMarkdownStyle from "./style.ts" ;
import {
autocompletion ,
closeBrackets ,
closeBracketsKeymap ,
codeFolding ,
completionKeymap ,
drawSelection ,
dropCursor ,
EditorState ,
EditorView ,
highlightSpecialChars ,
history ,
historyKeymap ,
indentOnInput ,
indentWithTab ,
KeyBinding ,
keymap ,
LanguageDescription ,
LanguageSupport ,
markdown ,
searchKeymap ,
standardKeymap ,
syntaxHighlighting ,
ViewPlugin ,
ViewUpdate ,
} from "../common/deps.ts" ;
2023-07-14 14:56:20 +00:00
import { Client } from "./client.ts" ;
2023-07-14 11:58:16 +00:00
import { vim } from "./deps.ts" ;
import { inlineImagesPlugin } from "./cm_plugins/inline_image.ts" ;
import { cleanModePlugins } from "./cm_plugins/clean.ts" ;
import { lineWrapper } from "./cm_plugins/line_wrapper.ts" ;
import { smartQuoteKeymap } from "./cm_plugins/smart_quotes.ts" ;
import { safeRun } from "../common/util.ts" ;
import { ClickEvent } from "$sb/app_event.ts" ;
import {
attachmentExtension ,
pasteLinkExtension ,
} from "./cm_plugins/editor_paste.ts" ;
2023-08-16 13:15:19 +00:00
import { TextChange } from "$sb/lib/change.ts" ;
2023-11-27 15:29:19 +00:00
import { postScriptPrefacePlugin } from "./cm_plugins/top_bottom_panels.ts" ;
2023-10-03 12:16:33 +00:00
import { languageFor } from "../common/languages.ts" ;
2023-11-21 15:24:20 +00:00
import { plugLinter } from "./cm_plugins/lint.ts" ;
2024-01-20 18:16:07 +00:00
import { Compartment , Extension } from "@codemirror/state" ;
2024-01-24 12:34:12 +00:00
import { extendedMarkdownLanguage } from "../common/markdown_parser/parser.ts" ;
2023-07-14 11:58:16 +00:00
export function createEditorState (
2023-11-21 15:24:20 +00:00
client : Client ,
2023-07-14 11:58:16 +00:00
pageName : string ,
text : string ,
readOnly : boolean ,
) : EditorState {
let touchCount = 0 ;
2024-01-20 18:16:07 +00:00
// Ugly: keep the keyhandler compartment in the client, to be replaced later once more commands are loaded
client . keyHandlerCompartment = new Compartment ( ) ;
const keyBindings = client . keyHandlerCompartment . of (
createKeyBindings ( client ) ,
) ;
2023-07-14 11:58:16 +00:00
return EditorState . create ( {
doc : text ,
extensions : [
// Not using CM theming right now, but some extensions depend on the "dark" thing
2023-07-14 12:22:26 +00:00
EditorView . theme ( { } , {
2023-11-21 15:24:20 +00:00
dark : client.ui.viewState.uiOptions.darkMode ,
2023-07-14 12:22:26 +00:00
} ) ,
2023-07-14 11:58:16 +00:00
// Enable vim mode, or not
[
2023-11-21 15:24:20 +00:00
. . . client . ui . viewState . uiOptions . vimMode ? [ vim ( { status : true } ) ] : [ ] ,
2023-07-14 12:22:26 +00:00
] ,
[
2023-11-21 15:24:20 +00:00
. . . readOnly || client . ui . viewState . uiOptions . forcedROMode
2023-07-14 11:58:16 +00:00
? [ readonlyMode ( ) ]
: [ ] ,
] ,
2023-11-21 15:24:20 +00:00
2023-07-14 11:58:16 +00:00
// The uber markdown mode
markdown ( {
2024-01-24 12:34:12 +00:00
base : extendedMarkdownLanguage ,
2023-10-03 12:16:33 +00:00
codeLanguages : ( info ) = > {
const lang = languageFor ( info ) ;
if ( lang ) {
return LanguageDescription . of ( {
name : info ,
support : new LanguageSupport ( lang ) ,
} ) ;
}
return null ;
} ,
2023-07-14 11:58:16 +00:00
addKeymap : true ,
} ) ,
2024-01-24 12:34:12 +00:00
extendedMarkdownLanguage . data . of ( {
2023-07-14 11:58:16 +00:00
closeBrackets : { brackets : [ "(" , "{" , "[" , "`" ] } ,
} ) ,
2024-01-24 12:34:12 +00:00
syntaxHighlighting ( customMarkdownStyle ( ) ) ,
2023-07-14 11:58:16 +00:00
autocompletion ( {
override : [
2023-11-21 15:24:20 +00:00
client . editorComplete . bind ( client ) ,
client . system . slashCommandHook . slashCommandCompleter . bind (
client . system . slashCommandHook ,
2023-07-14 11:58:16 +00:00
) ,
] ,
} ) ,
2023-11-21 15:24:20 +00:00
inlineImagesPlugin ( client ) ,
2023-07-14 11:58:16 +00:00
highlightSpecialChars ( ) ,
history ( ) ,
drawSelection ( ) ,
dropCursor ( ) ,
codeFolding ( {
placeholderText : "…" ,
} ) ,
indentOnInput ( ) ,
2023-11-21 15:24:20 +00:00
. . . cleanModePlugins ( client ) ,
2023-07-14 11:58:16 +00:00
EditorView . lineWrapping ,
2023-11-21 15:24:20 +00:00
plugLinter ( client ) ,
// lintGutter(),
// gutters(),
2023-11-25 12:40:56 +00:00
postScriptPrefacePlugin ( client ) ,
2023-07-14 11:58:16 +00:00
lineWrapper ( [
{ selector : "ATXHeading1" , class : "sb-line-h1" } ,
{ selector : "ATXHeading2" , class : "sb-line-h2" } ,
{ selector : "ATXHeading3" , class : "sb-line-h3" } ,
{ selector : "ATXHeading4" , class : "sb-line-h4" } ,
{ selector : "ListItem" , class : "sb-line-li" , nesting : true } ,
{ selector : "Blockquote" , class : "sb-line-blockquote" } ,
{ selector : "Task" , class : "sb-line-task" } ,
{ selector : "CodeBlock" , class : "sb-line-code" } ,
{
selector : "FencedCode" ,
class : "sb-line-fenced-code" ,
disableSpellCheck : true ,
} ,
{ selector : "Comment" , class : "sb-line-comment" } ,
{ selector : "BulletList" , class : "sb-line-ul" } ,
{ selector : "OrderedList" , class : "sb-line-ol" } ,
{ selector : "TableHeader" , class : "sb-line-tbl-header" } ,
{
2024-01-20 18:16:07 +00:00
selector : "FrontMatter" ,
class : "sb-frontmatter" ,
disableSpellCheck : true ,
2023-07-14 11:58:16 +00:00
} ,
] ) ,
2024-01-20 18:16:07 +00:00
keyBindings ,
2023-07-14 11:58:16 +00:00
EditorView . domEventHandlers ( {
// This may result in duplicated touch events on mobile devices
touchmove : ( ) = > {
touchCount ++ ;
} ,
touchend : ( event : TouchEvent , view : EditorView ) = > {
if ( touchCount === 0 ) {
safeRun ( async ( ) = > {
const touch = event . changedTouches . item ( 0 ) ! ;
if ( ! event . altKey && event . target instanceof Element ) {
// prevent the browser from opening the link twice
const parentA = event . target . closest ( "a" ) ;
if ( parentA ) {
event . preventDefault ( ) ;
}
}
2023-11-29 15:50:53 +00:00
const pos = view . posAtCoords ( {
x : touch.clientX ,
y : touch.clientY ,
} ) ! ;
const potentialClickEvent : ClickEvent = {
2023-07-14 11:58:16 +00:00
page : pageName ,
ctrlKey : event.ctrlKey ,
metaKey : event.metaKey ,
altKey : event.altKey ,
2023-11-29 15:50:53 +00:00
pos : pos ,
2023-07-14 11:58:16 +00:00
} ;
2023-11-29 15:50:53 +00:00
const distanceX = touch . clientX - view . coordsAtPos ( pos ) ! . left ;
// What we're trying to determine here is if the tap occured anywhere near the looked up position
// this may not be the case with locations that expand signifcantly based on live preview (such as links), we don't want any accidental clicks
// Fixes #585
//
if ( distanceX <= view . defaultCharacterWidth ) {
await client . dispatchAppEvent (
"page:click" ,
potentialClickEvent ,
) ;
}
2023-07-14 11:58:16 +00:00
} ) ;
}
touchCount = 0 ;
} ,
mousedown : ( event : MouseEvent , view : EditorView ) = > {
2024-01-13 17:42:40 +00:00
const pos = view . posAtCoords ( event ) ;
if ( event . button !== 0 ) {
return ;
}
if ( ! pos ) {
return ;
}
2023-07-14 11:58:16 +00:00
safeRun ( async ( ) = > {
const potentialClickEvent : ClickEvent = {
page : pageName ,
ctrlKey : event.ctrlKey ,
metaKey : event.metaKey ,
altKey : event.altKey ,
pos : view.posAtCoords ( {
x : event.x ,
y : event.y ,
} ) ! ,
} ;
// Make sure <a> tags are clicked without moving the cursor there
if ( ! event . altKey && event . target instanceof Element ) {
const parentA = event . target . closest ( "a" ) ;
if ( parentA ) {
event . stopPropagation ( ) ;
event . preventDefault ( ) ;
2023-11-21 15:24:20 +00:00
await client . dispatchAppEvent (
2023-07-14 11:58:16 +00:00
"page:click" ,
potentialClickEvent ,
) ;
return ;
}
}
const distanceX = event . x - view . coordsAtPos ( pos ) ! . left ;
// What we're trying to determine here is if the click occured anywhere near the looked up position
// this may not be the case with locations that expand signifcantly based on live preview (such as links), we don't want any accidental clicks
// Fixes #357
if ( distanceX <= view . defaultCharacterWidth ) {
2023-11-21 15:24:20 +00:00
await client . dispatchAppEvent ( "page:click" , potentialClickEvent ) ;
2023-07-14 11:58:16 +00:00
}
} ) ;
} ,
} ) ,
ViewPlugin . fromClass (
class {
update ( update : ViewUpdate ) : void {
if ( update . docChanged ) {
2023-08-16 13:15:19 +00:00
const changes : TextChange [ ] = [ ] ;
update . changes . iterChanges ( ( fromA , toA , fromB , toB , inserted ) = >
changes . push ( {
inserted : inserted.toString ( ) ,
oldRange : { from : fromA , to : toA } ,
newRange : { from : fromB , to : toB } ,
} )
) ;
2023-11-21 15:24:20 +00:00
client . dispatchAppEvent ( "editor:pageModified" , { changes } ) ;
client . ui . viewDispatch ( { type : "page-changed" } ) ;
client . debouncedUpdateEvent ( ) ;
client . save ( ) . catch ( ( e ) = > console . error ( "Error saving" , e ) ) ;
2023-07-14 11:58:16 +00:00
}
}
} ,
) ,
pasteLinkExtension ,
2023-11-21 15:24:20 +00:00
attachmentExtension ( client ) ,
2023-07-14 11:58:16 +00:00
closeBrackets ( ) ,
] ,
} ) ;
}
2024-01-20 18:16:07 +00:00
2024-01-24 13:03:14 +00:00
export function createCommandKeyBindings ( client : Client ) : KeyBinding [ ] {
2024-01-20 18:16:07 +00:00
const commandKeyBindings : KeyBinding [ ] = [ ] ;
// Track which keyboard shortcuts for which commands we've overridden, so we can skip them later
const overriddenCommands = new Set < string > ( ) ;
// Keyboard shortcuts from SETTINGS take precedense
if ( client . settings ? . shortcuts ) {
for ( const shortcut of client . settings . shortcuts ) {
// Figure out if we're using the command link syntax here, if so: parse it out
const commandMatch = commandLinkRegex . exec ( shortcut . command ) ;
let cleanCommandName = shortcut . command ;
let args : any [ ] = [ ] ;
if ( commandMatch ) {
cleanCommandName = commandMatch [ 1 ] ;
args = commandMatch [ 5 ] ? JSON . parse ( ` [ ${ commandMatch [ 5 ] } ] ` ) : [ ] ;
}
if ( args . length === 0 ) {
// If there was no "specialization" of this command (that is, we effectively created a keybinding for an existing command but with arguments), let's add it to the overridden command set:
overriddenCommands . add ( cleanCommandName ) ;
}
commandKeyBindings . push ( {
key : shortcut.key ,
mac : shortcut.mac ,
run : ( ) : boolean = > {
client . runCommandByName ( cleanCommandName , args ) . catch ( ( e : any ) = > {
console . error ( e ) ;
client . flashNotification (
` Error running command: ${ e . message } ` ,
"error" ,
) ;
} ) . then ( ( ) = > {
// Always be focusing the editor after running a command
client . focus ( ) ;
} ) ;
return true ;
} ,
} ) ;
}
}
// Then add bindings for plug commands
for ( const def of client . system . commandHook . editorCommands . values ( ) ) {
if ( def . command . key ) {
// If we've already overridden this command, skip it
if ( overriddenCommands . has ( def . command . key ) ) {
continue ;
}
commandKeyBindings . push ( {
key : def.command.key ,
mac : def.command.mac ,
run : ( ) : boolean = > {
if ( def . command . contexts ) {
const context = client . getContext ( ) ;
if ( ! context || ! def . command . contexts . includes ( context ) ) {
return false ;
}
}
Promise . resolve ( [ ] )
. then ( def . run )
. catch ( ( e : any ) = > {
console . error ( e ) ;
client . flashNotification (
` Error running command: ${ e . message } ` ,
"error" ,
) ;
} )
. then ( ( ) = > {
// Always be focusing the editor after running a command
client . focus ( ) ;
} ) ;
return true ;
} ,
} ) ;
}
}
2024-01-24 13:03:14 +00:00
return commandKeyBindings ;
}
export function createKeyBindings ( client : Client ) : Extension {
2024-01-20 18:16:07 +00:00
return keymap . of ( [
2024-01-24 13:03:14 +00:00
. . . createCommandKeyBindings ( client ) ,
2024-01-20 18:16:07 +00:00
. . . smartQuoteKeymap ,
. . . closeBracketsKeymap ,
. . . standardKeymap ,
. . . searchKeymap ,
. . . historyKeymap ,
. . . completionKeymap ,
indentWithTab ,
] ) ;
}