CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/editor.coffee
Views: 687
1
#########################################################################
2
# This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
# License: MS-RSL – see LICENSE.md for details
4
#########################################################################
5
6
$ = window.$
7
8
# Do this first, before any templates are initialized (e.g., elsewhere too).
9
10
{TEMPLATES_HTML} = require("./editor-templates")
11
12
$("body").append(TEMPLATES_HTML);
13
14
templates = $("#webapp-editor-templates")
15
16
{ init_buttonbars } = require("./editors/editor-button-bar")
17
init_buttonbars()
18
19
# Editor files in a project
20
# Show button labels if there are at most this many file tabs opened.
21
# This is in exports so that an elite user could customize this by doing, e.g.,
22
# require('./editor').SHOW_BUTTON_LABELS=0
23
exports.SHOW_BUTTON_LABELS = 4
24
25
exports.MIN_SPLIT = MIN_SPLIT = 0.02
26
exports.MAX_SPLIT = MAX_SPLIT = 0.98 # maximum pane split proportion for editing
27
28
TOOLTIP_DELAY = delay: {show: 500, hide: 100}
29
30
async = require('async')
31
32
message = require('@cocalc/util/message')
33
34
{redux} = require('./app-framework')
35
36
_ = underscore = require('underscore')
37
38
{webapp_client} = require('./webapp-client')
39
{EventEmitter} = require('events')
40
{alert_message} = require('./alerts')
41
{ appBasePath } = require("@cocalc/frontend/customize/app-base-path");
42
43
feature = require('./feature')
44
IS_MOBILE = feature.IS_MOBILE
45
46
misc = require('@cocalc/util/misc')
47
{drag_start_iframe_disable, drag_stop_iframe_enable, sagews_canonical_mode} = require('./misc')
48
49
# Ensure CodeMirror is available and configured
50
CodeMirror = require("codemirror")
51
52
# Ensure the console jquery plugin is available
53
require('./console')
54
55
# SMELL: undo doing the import below -- just use misc.[stuff] is more readable.
56
{copy, trunc, from_json, to_json, keys, defaults, required, filename_extension, filename_extension_notilde,
57
len, path_split, uuid} = require('@cocalc/util/misc')
58
59
syncdoc = require('./syncdoc')
60
sagews = require('./sagews/sagews')
61
printing = require('./printing')
62
63
{file_nonzero_size} = require('./project/utils')
64
65
copypaste = require('./copy-paste-buffer')
66
67
extra_alt_keys = (extraKeys, editor, opts) ->
68
misc.merge extraKeys,
69
"Shift-Alt-L" : (cm) => cm.align_assignments()
70
'Alt-Z' : (cm) => cm.undo()
71
'Shift-Alt-Z' : (cm) => cm.redo()
72
'Alt-A' : (cm) => cm.execCommand('selectAll')
73
'Shift-Alt-A' : (cm) => cm.execCommand('selectAll')
74
'Alt-K' : (cm) => cm.execCommand('killLine')
75
'Alt-D' : (cm) => cm.execCommand('selectNextOccurrence')
76
'Alt-F' : (cm) => cm.execCommand('find')
77
'Shift-Alt-F' : (cm) => cm.execCommand('replace')
78
'Shift-Alt-R' : (cm) => cm.execCommand('replaceAll')
79
'Shift-Alt-D' : (cm) => cm.execCommand('duplicateLine')
80
'Alt-G' : (cm) => cm.execCommand('findNext')
81
'Shift-Alt-G' : (cm) => cm.execCommand('findPrev')
82
'Alt-Up' : (cm) => cm.execCommand('goPageUp')
83
'Alt-Down' : (cm) => cm.execCommand('goPageDown')
84
'Alt-K' : (cm) => cm.execCommand('goPageUp')
85
'Alt-J' : (cm) => cm.execCommand('goPageDown')
86
'Alt-P' : (cm) => cm.execCommand('goLineUp')
87
'Alt-N' : (cm) => cm.execCommand('goLineDown')
88
89
if editor?.goto_line?
90
extraKeys['Alt-L'] = (cm) => editor.goto_line(cm)
91
if editor?.toggle_split_view?
92
extraKeys['Alt-I'] = (cm) => editor.toggle_split_view(cm)
93
if editor?.copy?
94
extraKeys['Alt-C'] = (cm) => editor.copy(cm) # gets overwritten for vim mode, of course
95
else
96
extraKeys['Alt-C'] = (cm) => copypaste.set_buffer(cm.getSelection())
97
98
if editor?.cut?
99
extraKeys['Alt-X'] = (cm) => editor.cut(cm)
100
else
101
extraKeys['Alt-X'] = (cm) =>
102
copypaste.set_buffer(cm.getSelection())
103
cm.replaceSelection('')
104
if editor?.paste?
105
extraKeys['Alt-V'] = (cm) => editor.paste(cm)
106
else
107
extraKeys['Alt-V'] = (cm) => cm.replaceSelection(copypaste.get_buffer())
108
if editor?.click_save_button?
109
extraKeys['Alt-S'] = (cm) => editor.click_save_button()
110
else if editor?.save?
111
extraKeys['Alt-S'] = (cm) => editor.save()
112
113
if opts.bindings == 'vim'
114
# An additional key to get to visual mode in vim (added for ipad Smart Keyboard)
115
extraKeys["Alt-C"] = (cm) =>
116
CodeMirror.Vim.exitInsertMode(cm)
117
extraKeys["Alt-F"] = (cm) =>
118
cm.execCommand('goPageDown')
119
extraKeys["Alt-B"] = (cm) =>
120
cm.execCommand('goPageUp')
121
122
123
{file_associations, VIDEO_EXTS} = require('./file-associations')
124
125
file_nonzero_size_cb = (project_id, path, cb) =>
126
try
127
if not await file_nonzero_size(project_id, path)
128
cb("Unable to convert file to PDF")
129
else
130
cb()
131
catch err
132
cb(err)
133
134
135
initialize_new_file_type_list = () ->
136
file_types_so_far = {}
137
v = misc.keys(file_associations)
138
v.sort()
139
f = (elt, ext, exclude) ->
140
if not ext
141
return
142
data = file_associations[ext]
143
if exclude and data.exclude_from_menu
144
return
145
if data.name? and not file_types_so_far[data.name]
146
file_types_so_far[data.name] = true
147
e = $("<li><a href='#new-file' data-ext='#{ext}'><i style='width: 18px;' class='fa #{data.icon}'></i> <span style='text-transform:capitalize'>#{data.name} </span> <span class='lighten'>(.#{ext})</span></a></li>")
148
elt.append(e)
149
150
elt = $(".smc-new-file-type-list")
151
for ext in v
152
f(elt, ext, true)
153
154
elt = $(".smc-mini-new-file-type-list")
155
file_types_so_far = {}
156
for ext in ['sagews', 'term', 'ipynb', 'tex', 'md', 'tasks', 'course', 'sage', 'py']
157
f(elt, ext)
158
elt.append($("<li class='divider'></li><li><a href='#new-folder'><i style='width: 18px;' class='fa fa-folder'></i> <span>Folder </span></a></li>"))
159
160
elt.append($("<li class='divider'></li><li><a href='#projects-add-collaborators'><i style='width: 18px;' class='fa fa-user'></i> <span>Collaborators... </span></a></li>"))
161
162
initialize_new_file_type_list()
163
164
exports.file_icon_class = file_icon_class = (ext) ->
165
assoc = exports.file_options('x.' + ext)
166
return assoc.icon
167
168
# This defines a bunch of custom modes and gets some info about special case of sagews
169
{sagews_decorator_modes} = require('./codemirror/custom-modes')
170
171
exports.file_options = require("./editor-tmp").file_options
172
173
# old "local storage" code was here, now moved to TS
174
editor_local_storage = require('./editor-local-storage')
175
exports.local_storage_delete = editor_local_storage.local_storage_delete
176
exports.local_storage = editor_local_storage.local_storage
177
local_storage = exports.local_storage # used below
178
179
###############################################
180
# Abstract base class for editors (not exports.Editor)
181
###############################################
182
# Derived classes must:
183
# (1) implement the _get and _set methods
184
# (2) show/hide/remove
185
#
186
# Events ensure that *all* users editor the same file see the same
187
# thing (synchronized).
188
#
189
190
class FileEditor extends EventEmitter
191
# ATTN it is crucial to call this constructor in subclasses via super(@project_id, @filename)
192
constructor: (project_id, filename) ->
193
super()
194
@project_id = project_id
195
@filename = filename
196
@ext = misc.filename_extension_notilde(@filename)?.toLowerCase()
197
@_show = underscore.debounce(@_show, 50)
198
199
is_active: () =>
200
misc.tab_to_path(redux.getProjectStore(@project_id).get('active_project_tab')) == @filename
201
202
# call it, to set the @default_font_size from the account settings
203
init_font_size: () =>
204
@default_font_size = redux.getStore('account').get('font_size')
205
206
val: (content) =>
207
if not content?
208
# If content not defined, returns current value.
209
return @_get()
210
else
211
# If content is defined, sets value.
212
@_set(content)
213
214
# has_unsaved_changes() returns the state, where true means that
215
# there are unsaved changed. To set the state, do
216
# has_unsaved_changes(true or false).
217
has_unsaved_changes: (val) =>
218
if not val?
219
return @_has_unsaved_changes
220
else
221
if not @_has_unsaved_changes? or @_has_unsaved_changes != val
222
if val
223
@save_button.removeClass('disabled')
224
else
225
@_when_had_no_unsaved_changes = new Date() # when we last knew for a fact there are no unsaved changes
226
@save_button.addClass('disabled')
227
@_has_unsaved_changes = val
228
229
# committed means "not saved to the database/server", whereas save above
230
# means "saved to *disk*".
231
has_uncommitted_changes: (val) =>
232
if not val?
233
return @_has_uncommitted_changes
234
else
235
@_has_uncommitted_changes = val
236
if val
237
if not @_show_uncommitted_warning_timeout?
238
# We have not already started a timer, so start one -- if we do not hear otherwise, show
239
# the warning in 30s.
240
@_show_uncommitted_warning_timeout = setTimeout((()=>@_show_uncommitted_warning()), 30000)
241
else
242
if @_show_uncommitted_warning_timeout?
243
clearTimeout(@_show_uncommitted_warning_timeout)
244
delete @_show_uncommitted_warning_timeout
245
@uncommitted_element?.hide()
246
247
_show_uncommitted_warning: () =>
248
delete @_show_uncommitted_warning_timeout
249
@uncommitted_element?.show()
250
251
focus: () => # FUTURE in derived class (???)
252
253
_get: () =>
254
console.warn("Incomplete: editor -- needs to implement _get in derived class")
255
256
_set: (content) =>
257
console.warn("Incomplete: editor -- needs to implement _set in derived class")
258
259
restore_cursor_position: () =>
260
# implement in a derived class if you need this
261
262
disconnect_from_session: (cb) =>
263
# implement in a derived class if you need this
264
265
local_storage: (key, value) =>
266
return local_storage(@project_id, @filename, key, value)
267
268
show: (opts) =>
269
if not opts?
270
if @_last_show_opts?
271
opts = @_last_show_opts
272
else
273
opts = {}
274
@_last_show_opts = opts
275
276
# only re-render the editor if it is active. that's crucial, because e.g. the autosave
277
# of latex triggers a build, which in turn calls @show to update itself. that would cause
278
# the latex editor to be visible despite not being the active editor.
279
if not @is_active?()
280
return
281
282
@element.show()
283
# if above line reveals it, give it a bit time to do the layout first
284
@_show(opts) # critical -- also do an initial layout! Otherwise get a horrible messed up animation effect.
285
setTimeout((=> @_show(opts)), 10)
286
if DEBUG
287
window?.smc?.doc = @ # useful for debugging...
288
289
_show: (opts={}) =>
290
# define in derived class
291
292
hide: () =>
293
#@element?.hide()
294
295
remove: () =>
296
@syncdoc?.close()
297
@element?.remove()
298
@removeAllListeners()
299
300
terminate_session: () =>
301
# If some backend session on a remote machine is serving this session, terminate it.
302
303
exports.FileEditor = FileEditor
304
305
###############################################
306
# Codemirror-based File Editor
307
308
# - 'saved' : when the file is successfully saved by the user
309
# - 'show' :
310
# - 'toggle-split-view' :
311
###############################################
312
class CodeMirrorEditor extends FileEditor
313
constructor: (project_id, filename, content, opts) ->
314
super(project_id, filename)
315
316
editor_settings = redux.getStore('account').get_editor_settings()
317
opts = @opts = defaults opts,
318
mode : undefined
319
geometry : undefined # (default=full screen);
320
read_only : false
321
delete_trailing_whitespace: editor_settings.strip_trailing_whitespace # delete on save
322
show_trailing_whitespace : editor_settings.show_trailing_whitespace
323
allow_javascript_eval : true # if false, the one use of eval isn't allowed.
324
line_numbers : editor_settings.line_numbers
325
first_line_number : editor_settings.first_line_number
326
indent_unit : editor_settings.indent_unit
327
tab_size : editor_settings.tab_size
328
smart_indent : editor_settings.smart_indent
329
electric_chars : editor_settings.electric_chars
330
undo_depth : editor_settings.undo_depth # no longer relevant, since done via sync system
331
match_brackets : editor_settings.match_brackets
332
code_folding : editor_settings.code_folding
333
auto_close_brackets : editor_settings.auto_close_brackets
334
match_xml_tags : editor_settings.match_xml_tags
335
auto_close_xml_tags : editor_settings.auto_close_xml_tags
336
line_wrapping : editor_settings.line_wrapping
337
spaces_instead_of_tabs : editor_settings.spaces_instead_of_tabs
338
style_active_line : 15 # editor_settings.style_active_line # (a number between 0 and 127)
339
bindings : editor_settings.bindings # 'standard', 'vim', or 'emacs'
340
theme : editor_settings.theme
341
track_revisions : editor_settings.track_revisions
342
public_access : false
343
latex_editor : false
344
345
# I'm making the times below very small for now. If we have to adjust these to reduce load, due to lack
346
# of capacity, then we will. Or, due to lack of optimization (e.g., for big documents). These parameters
347
# below would break editing a huge file right now, due to slowness of applying a patch to a codemirror editor.
348
349
cursor_interval : 1000 # minimum time (in ms) between sending cursor position info to hub -- used in sync version
350
sync_interval : 500 # minimum time (in ms) between synchronizing text with hub. -- used in sync version below
351
352
completions_size : 20 # for tab completions (when applicable, e.g., for sage sessions)
353
354
#console.log("mode =", opts.mode)
355
356
@element = templates.find(".webapp-editor-codemirror").clone()
357
358
@element.data('editor', @)
359
360
@init_save_button()
361
@init_uncommitted_element()
362
@init_history_button()
363
@init_edit_buttons()
364
365
@init_file_actions()
366
367
filename = @filename
368
if filename.length > 30
369
filename = "…" + filename.slice(filename.length-30)
370
371
# not really needed due to highlighted tab; annoying.
372
#@element.find(".webapp-editor-codemirror-filename").text(filename)
373
374
@show_exec_warning = redux.getStore('account').getIn(['editor_settings', 'show_exec_warning']) ? true
375
if @show_exec_warning and @ext in ['py', 'r', 'sage', 'f90']
376
msg = "<strong>INFO:</strong> you can only run <code>*.#{@ext}</code> files in a terminal or create a worksheet/notebook. <a href='#'>Close</a>"
377
msg_el = @element.find('.webapp-editor-codemirror-message')
378
msg_el.html(msg)
379
msg_el.find('a').click ->
380
msg_el.hide()
381
redux.getTable('account').set(editor_settings:{show_exec_warning:false})
382
383
@_video_is_on = @local_storage("video_is_on")
384
if not @_video_is_on?
385
@_video_is_on = false
386
387
extraKeys =
388
"Alt-Enter" : (editor) => @action_key(execute: true, advance:false, split:false)
389
"Cmd-Enter" : (editor) => @action_key(execute: true, advance:false, split:false)
390
"Ctrl-Enter" : (editor) => @action_key(execute: true, advance:true, split:true)
391
"Ctrl-;" : (editor) => @action_key(split:true, execute:false, advance:false)
392
"Cmd-;" : (editor) => @action_key(split:true, execute:false, advance:false)
393
"Ctrl-\\" : (editor) => @action_key(execute:false, toggle_input:true)
394
#"Cmd-x" : (editor) => @action_key(execute:false, toggle_input:true)
395
"Shift-Ctrl-\\" : (editor) => @action_key(execute:false, toggle_output:true)
396
#"Shift-Cmd-y" : (editor) => @action_key(execute:false, toggle_output:true)
397
398
"Cmd-S" : (editor) => @click_save_button()
399
"Alt-S" : (editor) => @click_save_button()
400
401
"Ctrl-L" : (editor) => @goto_line(editor)
402
"Cmd-L" : (editor) => @goto_line(editor)
403
404
"Shift-Ctrl-I" : (editor) => @toggle_split_view(editor)
405
"Shift-Cmd-I" : (editor) => @toggle_split_view(editor)
406
407
"Shift-Cmd-L" : (editor) => editor.align_assignments()
408
"Shift-Ctrl-L" : (editor) => editor.align_assignments()
409
410
"Shift-Ctrl-." : (editor) => @change_font_size(editor, +1)
411
"Shift-Ctrl-," : (editor) => @change_font_size(editor, -1)
412
413
"Shift-Cmd-." : (editor) => @change_font_size(editor, +1)
414
"Shift-Cmd-," : (editor) => @change_font_size(editor, -1)
415
416
"Shift-Tab" : (editor) => editor.unindent_selection()
417
418
"Ctrl-'" : "indentAuto"
419
"Cmd-'" : "indentAuto"
420
421
"Cmd-/" : "toggleComment"
422
"Ctrl-/" : "toggleComment" # shortcut chosen by jupyter project (undocumented)
423
424
"Tab" : (editor) => @press_tab_key(editor)
425
"Shift-Ctrl-C" : (editor) => @interrupt_key()
426
427
"Ctrl-Space" : "autocomplete"
428
"Alt-Space": "autocomplete"
429
430
if feature.IS_TOUCH
431
# Better more external keyboard friendly shortcuts, motivated by iPad.
432
extra_alt_keys(extraKeys, @, opts)
433
434
if opts.match_xml_tags
435
extraKeys['Ctrl-J'] = "toMatchingTag"
436
437
if opts.bindings != 'emacs'
438
# Emacs uses control s for find.
439
extraKeys["Ctrl-S"] = (editor) => @click_save_button()
440
441
# FUTURE: We will replace this by a general framework...
442
if misc.filename_extension_notilde(filename).toLowerCase() == "sagews"
443
evaluate_key = redux.getStore('account').get('evaluate_key').toLowerCase()
444
if evaluate_key == "enter"
445
evaluate_key = "Enter"
446
else
447
evaluate_key = "Shift-Enter"
448
extraKeys[evaluate_key] = (editor) => @action_key(execute: true, advance:true, split:false)
449
else
450
extraKeys["Shift-Enter"] = =>
451
alert_message
452
type : "error"
453
message : "You can only evaluate code in a file that ends with the extension 'sagews' or 'ipynb'. Create a Sage Worksheet or Jupyter notebook instead."
454
455
# Layouts:
456
# 0 - one single editor
457
# 1 - two editors, one on top of the other
458
# 2 - two editors, one next to the other
459
460
if IS_MOBILE
461
@_layout = 0
462
else
463
@_layout = @local_storage("layout") ? 0 # WARNING/UGLY: used by syncdoc.coffee and sagews.coffee !
464
if @_layout not in [0, 1, 2]
465
# IMPORTANT: If this were anything other than what is listed, the user
466
# would never be able to open tex files. So it's important that this be valid.
467
@_layout = 0
468
@_last_layout = undefined
469
470
if feature.isMobile.Android()
471
# see https://github.com/sragemathinc/smc/issues/1360
472
opts.style_active_line = false
473
474
make_editor = (node) =>
475
options =
476
firstLineNumber : opts.first_line_number
477
autofocus : false
478
mode : {name:opts.mode, globalVars: true}
479
lineNumbers : opts.line_numbers
480
showTrailingSpace : opts.show_trailing_whitespace
481
indentUnit : opts.indent_unit
482
tabSize : opts.tab_size
483
smartIndent : opts.smart_indent
484
electricChars : opts.electric_chars
485
undoDepth : opts.undo_depth
486
matchBrackets : opts.match_brackets
487
autoCloseBrackets : opts.auto_close_brackets and (misc.filename_extension_notilde(filename) not in ['hs', 'lhs']) #972
488
autoCloseTags : opts.auto_close_xml_tags
489
lineWrapping : opts.line_wrapping
490
readOnly : opts.read_only
491
styleActiveLine : opts.style_active_line
492
indentWithTabs : not opts.spaces_instead_of_tabs
493
showCursorWhenSelecting : true
494
extraKeys : extraKeys
495
cursorScrollMargin : 6
496
viewportMargin : Infinity # larger than the default of 10 specifically so *sage worksheets* (which are the only thing that uses this)
497
# don't feel jumpy when re-rendering output.
498
# NOTE that in cocalc right now, no remaining non-sagews editors use this code.
499
# with images, even using a viewport of 300 causes major jumpiness problems with images.
500
# See https://github.com/sagemathinc/cocalc/issues/7654
501
# Browser caching may have changed in newer browsers as well making 300 feel jumpy in "modern times".
502
503
if opts.match_xml_tags
504
options.matchTags = {bothTags: true}
505
506
if opts.code_folding
507
extraKeys["Ctrl-Q"] = (cm) -> cm.foldCodeSelectionAware()
508
extraKeys["Alt-Q"] = (cm) -> cm.foldCodeSelectionAware()
509
options.foldGutter = true
510
options.gutters = ["CodeMirror-linenumbers", "CodeMirror-foldgutter"]
511
512
if opts.latex_editor
513
options.gutters ?= []
514
options.gutters.push("Codemirror-latex-errors")
515
516
if opts.bindings? and opts.bindings != "standard"
517
options.keyMap = opts.bindings
518
#cursorBlinkRate: 1000
519
520
if opts.theme? and opts.theme != "standard"
521
options.theme = opts.theme
522
523
cm = CodeMirror.fromTextArea(node, options)
524
cm.save = () => @click_save_button()
525
526
# The Codemirror themes impose their own weird fonts, but most users want whatever
527
# they've configured as "monospace" in their browser. So we force that back:
528
e = $(cm.getWrapperElement())
529
e.attr('style', e.attr('style') + '; height:100%; font-family:monospace !important;')
530
# see http://stackoverflow.com/questions/2655925/apply-important-css-style-using-jquery
531
532
if opts.bindings == 'vim'
533
# annoying due to api change in vim mode
534
cm.setOption("vimMode", true)
535
536
return cm
537
538
elt = @element.find(".webapp-editor-textarea-0"); elt.text(content)
539
540
@codemirror = make_editor(elt[0])
541
@codemirror.name = '0'
542
#window.cm = @codemirror
543
544
elt1 = @element.find(".webapp-editor-textarea-1")
545
546
@codemirror1 = make_editor(elt1[0])
547
@codemirror1.name = '1'
548
549
buf = @codemirror.linkedDoc({sharedHist: true})
550
@codemirror1.swapDoc(buf)
551
552
@codemirror.on 'focus', () =>
553
@codemirror_with_last_focus = @codemirror
554
555
@codemirror1.on 'focus', () =>
556
@codemirror_with_last_focus = @codemirror1
557
558
if @opts.bindings == 'vim'
559
@_vim_mode = 'visual'
560
@codemirror.on 'vim-mode-change', (obj) =>
561
if obj.mode == 'normal'
562
@_vim_mode = 'visual'
563
@element.find("a[href='#vim-mode-toggle']").text('esc')
564
else
565
@_vim_mode = 'insert'
566
@element.find("a[href='#vim-mode-toggle']").text('i')
567
568
if feature.IS_TOUCH
569
# ugly hack so more usable on touch...
570
@element.find(".webapp-editor-resize-bar-layout-1").height('12px')
571
@element.find(".webapp-editor-resize-bar-layout-2").width('12px')
572
573
@init_font_size() # get the @default_font_size
574
@restore_font_size()
575
576
@init_draggable_splits()
577
578
if opts.read_only
579
@set_readonly_ui()
580
581
if misc.filename_extension(@filename)?.toLowerCase() == 'sagews'
582
@init_sagews_edit_buttons()
583
584
@snippets_dialog = null
585
# Render all icons using React.
586
@element.processIcons()
587
588
programmatical_goto_line: (line) =>
589
cm = @codemirror_with_last_focus
590
return if not cm?
591
pos = {line:line-1, ch:0}
592
info = cm.getScrollInfo()
593
cm.scrollIntoView(pos, info.clientHeight/2)
594
595
get_users_cursors: (account_id) =>
596
return @syncdoc?.get_users_cursors(account_id)
597
598
init_file_actions: () =>
599
if not @element?
600
return
601
dom_node = @element.find('.smc-editor-file-info-dropdown')[0]
602
require('./editors/file-info-dropdown').render_file_info_dropdown(@filename, @project_id, dom_node, @opts.public_access)
603
604
init_draggable_splits: () =>
605
@_layout1_split_pos = @local_storage("layout1_split_pos")
606
@_layout2_split_pos = @local_storage("layout2_split_pos")
607
608
layout1_bar = @element.find(".webapp-editor-resize-bar-layout-1")
609
layout1_bar.draggable
610
axis : 'y'
611
containment : @element
612
zIndex : 10
613
start : drag_start_iframe_disable
614
stop : (event, ui) =>
615
drag_stop_iframe_enable()
616
# compute the position of bar as a number from 0 to 1, with
617
# 0 being at top (left), 1 at bottom (right), and .5 right in the middle
618
e = @element.find(".webapp-editor-codemirror-input-container-layout-1")
619
top = e.offset().top
620
ht = e.height()
621
p = layout1_bar.offset().top + layout1_bar.height()/2
622
@_layout1_split_pos = (p - top) / ht
623
@local_storage("layout1_split_pos", @_layout1_split_pos)
624
# redraw, which uses split info
625
@show()
626
627
layout2_bar = @element.find(".webapp-editor-resize-bar-layout-2")
628
layout2_bar.draggable
629
axis : 'x'
630
containment : @element
631
zIndex : 100
632
start : drag_start_iframe_disable
633
stop : (event, ui) =>
634
drag_stop_iframe_enable()
635
# compute the position of bar as a number from 0 to 1, with
636
# 0 being at top (left), 1 at bottom (right), and .5 right in the middle
637
e = @element.find(".webapp-editor-codemirror-input-container-layout-2")
638
left = e.offset().left
639
width = e.width()
640
p = layout2_bar.offset().left
641
@_layout2_split_pos = (p - left) / width
642
@local_storage("layout2_split_pos", @_layout2_split_pos)
643
# redraw, which uses split info
644
@show()
645
646
hide_content: () =>
647
@element.find(".webapp-editor-codemirror-content").hide()
648
649
show_content: () =>
650
@hide_startup_message()
651
@element.find(".webapp-editor-codemirror-content").show()
652
for cm in @codemirrors()
653
cm_refresh(cm)
654
655
hide_startup_message: () =>
656
@element.find(".webapp-editor-codemirror-startup-message").hide()
657
658
show_startup_message: (mesg, type='info') =>
659
@hide_content()
660
if typeof(mesg) != 'string'
661
mesg = JSON.stringify(mesg)
662
e = @element.find(".webapp-editor-codemirror-startup-message").show().text(mesg)
663
for t in ['success', 'info', 'warning', 'danger']
664
e.removeClass("alert-#{t}")
665
e.addClass("alert-#{type}")
666
667
is_active: () =>
668
return @codemirror? and misc.tab_to_path(redux.getProjectStore(@project_id).get('active_project_tab')) == @filename
669
670
set_theme: (theme) =>
671
# Change the editor theme after the editor has been created
672
for cm in @codemirrors()
673
cm.setOption('theme', theme)
674
@opts.theme = theme
675
676
# add something visual to the UI to suggest that the file is read only
677
set_readonly_ui: (readonly=true) =>
678
@opts.read_only = readonly
679
@element.find(".webapp-editor-write-only").toggle(!readonly)
680
@element.find(".webapp-editor-read-only").toggle(readonly)
681
for cm in @codemirrors()
682
cm.setOption('readOnly', readonly)
683
684
set_cursor_center_focus: (pos, tries=5) =>
685
if tries <= 0
686
return
687
cm = @codemirror_with_last_focus
688
if not cm?
689
cm = @codemirror
690
if not cm?
691
return
692
cm.setCursor(pos)
693
info = cm.getScrollInfo()
694
try
695
# This call can fail during editor initialization (as of codemirror 3.19, but not before).
696
cm.scrollIntoView(pos, info.clientHeight/2)
697
catch e
698
setTimeout((() => @set_cursor_center_focus(pos, tries-1)), 250)
699
cm.focus()
700
701
disconnect_from_session: (cb) =>
702
# implement in a derived class if you need this
703
@syncdoc?.disconnect_from_session()
704
cb?()
705
706
codemirrors: () =>
707
c = [@codemirror, @codemirror1]
708
return underscore.filter(c, ((x) -> x?))
709
710
focused_codemirror: () =>
711
if @codemirror_with_last_focus?
712
return @codemirror_with_last_focus
713
else
714
return @codemirror
715
716
action_key: (opts) =>
717
# opts ignored by default; worksheets use them....
718
@click_save_button()
719
720
interrupt_key: () =>
721
# does nothing for generic editor, but important, e.g., for the sage worksheet editor.
722
723
press_tab_key: (editor) =>
724
if editor.somethingSelected()
725
CodeMirror.commands.defaultTab(editor)
726
else
727
@tab_nothing_selected(editor)
728
729
tab_nothing_selected: (editor) =>
730
if @opts.spaces_instead_of_tabs
731
editor.tab_as_space()
732
else
733
CodeMirror.commands.defaultTab(editor)
734
735
init_edit_buttons: () =>
736
that = @
737
button_names = ['search', 'next', 'prev', 'replace', 'undo', 'redo', 'autoindent',
738
'shift-left', 'shift-right', 'split-view','increase-font', 'decrease-font', 'goto-line',
739
'copy', 'paste', 'vim-mode-toggle']
740
741
if @opts.bindings != 'vim'
742
@element.find("a[href='#vim-mode-toggle']").remove()
743
744
# if the file extension indicates that we know how to print it, show and enable the print button
745
if printing.can_print(@ext)
746
button_names.push('print')
747
else
748
@element.find('a[href="#print"]').remove()
749
750
# sagews2pdf conversion
751
if @ext == 'sagews'
752
button_names.push('sagews2pdf')
753
button_names.push('sagews2ipynb')
754
else
755
@element.find('a[href="#sagews2pdf"]').remove()
756
@element.find('a[href="#sagews2ipynb"]').remove()
757
758
for name in button_names
759
e = @element.find("a[href=\"##{name}\"]")
760
e.data('name', name).tooltip(delay:{ show: 500, hide: 100 }).click (event) ->
761
that.click_edit_button($(@).data('name'))
762
return false
763
764
click_edit_button: (name) =>
765
cm = @codemirror_with_last_focus
766
if not cm?
767
cm = @codemirror
768
if not cm?
769
return
770
switch name
771
when 'search'
772
CodeMirror.commands.find(cm)
773
when 'next'
774
if cm._searchState?.query
775
CodeMirror.commands.findNext(cm)
776
else
777
CodeMirror.commands.goPageDown(cm)
778
cm.focus()
779
when 'prev'
780
if cm._searchState?.query
781
CodeMirror.commands.findPrev(cm)
782
else
783
CodeMirror.commands.goPageUp(cm)
784
cm.focus()
785
when 'replace'
786
CodeMirror.commands.replace(cm)
787
when 'undo'
788
cm.undo()
789
cm.focus()
790
when 'redo'
791
cm.redo()
792
cm.focus()
793
when 'split-view'
794
@toggle_split_view(cm)
795
when 'autoindent'
796
CodeMirror.commands.indentAuto(cm)
797
when 'shift-left'
798
cm.unindent_selection()
799
cm.focus()
800
when 'shift-right'
801
@press_tab_key(cm)
802
cm.focus()
803
when 'increase-font'
804
@change_font_size(cm, +1)
805
cm.focus()
806
when 'decrease-font'
807
@change_font_size(cm, -1)
808
cm.focus()
809
when 'goto-line'
810
@goto_line(cm)
811
when 'copy'
812
@copy(cm)
813
cm.focus()
814
when 'paste'
815
@paste(cm)
816
cm.focus()
817
when 'sagews2pdf'
818
@print(sagews2html = false)
819
when 'sagews2ipynb'
820
@convert_to_ipynb()
821
when 'print'
822
@print(sagews2html = true)
823
when 'vim-mode-toggle'
824
if @_vim_mode == 'visual'
825
CodeMirror.Vim.handleKey(cm, 'i')
826
else
827
CodeMirror.Vim.exitInsertMode(cm)
828
cm.focus()
829
830
restore_font_size: () =>
831
# we set the font_size from local storage
832
# or fall back to the default from the account settings
833
for i, cm of @codemirrors()
834
size = @local_storage("font_size#{i}")
835
if size?
836
@set_font_size(cm, size)
837
else if @default_font_size?
838
@set_font_size(cm, @default_font_size)
839
840
get_font_size: (cm) ->
841
if not cm?
842
return
843
elt = $(cm.getWrapperElement())
844
return elt.data('font-size') ? @default_font_size
845
846
set_font_size: (cm, size) =>
847
if not cm?
848
return
849
if size > 1
850
elt = $(cm.getWrapperElement())
851
elt.css('font-size', size + 'px')
852
elt.data('font-size', size)
853
854
change_font_size: (cm, delta) =>
855
if not cm?
856
return
857
#console.log("change_font_size #{cm.name}, #{delta}")
858
scroll_before = cm.getScrollInfo()
859
860
elt = $(cm.getWrapperElement())
861
size = elt.data('font-size')
862
if not size?
863
s = elt.css('font-size')
864
size = parseInt(s.slice(0,s.length-2))
865
new_size = size + delta
866
@set_font_size(cm, new_size)
867
@local_storage("font_size#{cm.name}", new_size)
868
869
# we have to do the scrollTo in the next render loop, since otherwise
870
# the getScrollInfo function below will return the sizing data about
871
# the cm instance before the above css font-size change has been rendered.
872
f = () =>
873
cm_refresh(cm)
874
scroll_after = cm.getScrollInfo()
875
x = (scroll_before.left / scroll_before.width) * scroll_after.width
876
y = (((scroll_before.top+scroll_before.clientHeight/2) / scroll_before.height) * scroll_after.height) - scroll_after.clientHeight/2
877
cm.scrollTo(x, y)
878
setTimeout(f, 0)
879
880
toggle_split_view: (cm) =>
881
if not cm?
882
return
883
@_layout = (@_layout + 1) % 3
884
@local_storage("layout", @_layout)
885
@show()
886
if cm? and not feature.IS_TOUCH
887
if @_layout > 0
888
cm.focus()
889
else
890
# focus first editor since it is only one that is visible.
891
@codemirror.focus()
892
f = () =>
893
for x in @codemirrors()
894
x.scrollIntoView() # scroll the cursors back into view -- see https://github.com/sagemathinc/cocalc/issues/1044
895
setTimeout(f, 1) # wait until next loop after codemirror has laid itself out.
896
@emit 'toggle-split-view'
897
898
goto_line: (cm) =>
899
if not cm?
900
return
901
focus = () =>
902
@focus()
903
cm.focus()
904
dialog = templates.find(".webapp-goto-line-dialog").clone()
905
dialog.modal('show')
906
dialog.find(".btn-close").off('click').click () ->
907
dialog.modal('hide')
908
setTimeout(focus, 50)
909
return false
910
input = dialog.find(".webapp-goto-line-input")
911
input.val(cm.getCursor().line+1) # +1 since line is 0-based
912
dialog.find(".webapp-goto-line-range").text("1-#{cm.lineCount()} or n%")
913
dialog.find(".webapp-goto-line-input").focus().select()
914
submit = () =>
915
dialog.modal('hide')
916
result = input.val().trim()
917
if result.length >= 1 and result[result.length-1] == '%'
918
line = Math.floor( cm.lineCount() * parseInt(result.slice(0,result.length-1)) / 100.0)
919
else
920
line = Math.min(parseInt(result)-1)
921
if line >= cm.lineCount()
922
line = cm.lineCount() - 1
923
if line <= 0
924
line = 0
925
pos = {line:line, ch:0}
926
cm.setCursor(pos)
927
info = cm.getScrollInfo()
928
cm.scrollIntoView(pos, info.clientHeight/2)
929
setTimeout(focus, 50)
930
dialog.find(".btn-submit").off('click').click(submit)
931
input.keydown (evt) =>
932
if evt.which == 13 # enter
933
submit()
934
return false
935
if evt.which == 27 # escape
936
setTimeout(focus, 50)
937
dialog.modal('hide')
938
return false
939
940
copy: (cm) =>
941
if not cm?
942
return
943
copypaste.set_buffer(cm.getSelection())
944
945
convert_to_ipynb: () =>
946
p = misc.path_split(@filename)
947
v = p.tail.split('.')
948
if v.length <= 1
949
ext = ''
950
base = p.tail
951
else
952
ext = v[v.length-1]
953
base = v.slice(0,v.length-1).join('.')
954
955
if ext != 'sagews'
956
console.error("editor.print called on file with extension '#{ext}' but only supports 'sagews'.")
957
return
958
959
async.series([
960
(cb) =>
961
@save(cb)
962
(cb) =>
963
webapp_client.exec
964
project_id : @project_id
965
command : "cc-sagews2ipynb"
966
args : [@filename]
967
err_on_exit : true
968
cb : (err, output) =>
969
if err
970
alert_message(type:"error", message:"Error occured converting '#{@filename}' -- #{err}")
971
else
972
path = base + '.ipynb'
973
if p.head
974
path = p.head + '/' + path
975
redux.getProjectActions(@project_id).open_file
976
path : path
977
foreground : true
978
])
979
980
cut: (cm) =>
981
if not cm?
982
return
983
copypaste.set_buffer(cm.getSelection())
984
cm.replaceSelection('')
985
986
paste: (cm) =>
987
if not cm?
988
return
989
cm.replaceSelection(copypaste.get_buffer())
990
991
print: (sagews2html = true) =>
992
switch @ext
993
when 'sagews'
994
if sagews2html
995
@print_html()
996
else
997
@print_sagews()
998
when 'txt', 'csv'
999
print_button = @element.find('a[href="#print"]')
1000
print_button.icon_spin(start:true, delay:0).addClass("disabled")
1001
printing.Printer(@, @filename + '.pdf').print (err) ->
1002
print_button.removeClass('disabled')
1003
print_button.icon_spin(false)
1004
if err
1005
alert_message
1006
type : "error"
1007
message : "Printing error -- #{err}"
1008
1009
print_html: =>
1010
dialog = null
1011
d_content = null
1012
d_open = null
1013
d_download = null
1014
d_progress = _.noop
1015
output_fn = null # set this before showing the dialog
1016
1017
show_dialog = (cb) =>
1018
# this creates the dialog element and defines the action functions like d_progress
1019
dialog = $("""
1020
<div class="modal" tabindex="-1" role="dialog">
1021
<div class="modal-dialog" role="document">
1022
<div class="modal-content">
1023
<div class="modal-header">
1024
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
1025
<h4 class="modal-title">Print to HTML</h4>
1026
</div>
1027
<div class="modal-body">
1028
<div class="progress">
1029
<div class="progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%;">
1030
0 %
1031
</div>
1032
</div>
1033
<div class="content" style="text-align: center;"></div>
1034
<div style="margin-top: 25px;">
1035
<p><b>More information</b></p>
1036
<p>
1037
This SageWS to HTML conversion transforms the current worksheet
1038
to a static HTML file.
1039
<br/>
1040
<a href="https://github.com/sagemathinc/cocalc/wiki/sagews2html" target='_blank'>Click here for more information</a>.
1041
</p>
1042
</div>
1043
</div>
1044
<div class="modal-footer">
1045
<button type="button" class="btn-download btn btn-primary disabled">Download</button>
1046
<button type="button" class="btn-open btn btn-success disabled">Open</button>
1047
<button type="button" class="btn-close btn btn-default" data-dismiss="modal">Close</button>
1048
</div>
1049
</div>
1050
</div>
1051
</div>
1052
""")
1053
d_content = dialog.find('.content')
1054
d_open = dialog.find('.btn-open')
1055
d_download = dialog.find('.btn-download')
1056
action = redux.getProjectActions(@project_id)
1057
d_progress = (p) ->
1058
pct = "#{Math.round(100 * p)}%"
1059
dialog.find(".progress-bar").css('width', pct).text(pct)
1060
dialog.find('.btn-close').click ->
1061
dialog.modal('hide')
1062
return false
1063
d_open.click =>
1064
action.download_file
1065
path : output_fn
1066
auto : false # open in new tab
1067
d_download.click =>
1068
action.download_file
1069
path : output_fn
1070
auto : true
1071
dialog.modal('show')
1072
cb()
1073
1074
convert = (cb) =>
1075
# initiates the actual conversion via printing.Printer ...
1076
switch @ext
1077
when 'sagews'
1078
output_fn = @filename + '.html'
1079
progress = (percent, mesg) =>
1080
d_content.text(mesg)
1081
d_progress(percent)
1082
progress = _.debounce(progress, 5)
1083
progress(.01, "Loading ...")
1084
done = (err) =>
1085
#console.log 'Printer.print_html is done: err = ', err
1086
if err
1087
progress(0, "Problem printing to HTML: #{err}")
1088
else
1089
progress(1, 'Printing finished.')
1090
# enable open & download buttons
1091
dialog.find('button.btn').removeClass('disabled')
1092
printing.Printer(@, output_fn).print(done, progress)
1093
cb(); return
1094
1095
# fallback
1096
cb("err -- unable to convert files with extension '@ext'")
1097
1098
async.series([show_dialog, convert], (err) =>
1099
if err
1100
msg = "problem printing -- #{misc.to_json(err)}"
1101
alert_message
1102
type : "error"
1103
message : msg
1104
dialog.content.text(msg)
1105
)
1106
1107
# WARNING: this "print" is actually for printing Sage worksheets, not arbitrary files.
1108
print_sagews: =>
1109
dialog = templates.find(".webapp-file-print-dialog").clone()
1110
dialog.processIcons()
1111
p = misc.path_split(@filename)
1112
v = p.tail.split('.')
1113
if v.length <= 1
1114
ext = ''
1115
base = p.tail
1116
else
1117
ext = v[v.length-1]
1118
base = v.slice(0,v.length-1).join('.')
1119
1120
ext = ext.toLowerCase()
1121
if ext != 'sagews'
1122
console.error("editor.print called on file with extension '#{ext}' but only supports 'sagews'.")
1123
return
1124
1125
submit = () =>
1126
dialog.find(".webapp-file-printing-progress").show()
1127
dialog.find(".webapp-file-printing-link").hide()
1128
$print_tempdir = dialog.find(".smc-file-printing-tempdir")
1129
$print_tempdir.hide()
1130
is_subdir = dialog.find(".webapp-file-print-keepfiles").is(":checked")
1131
dialog.find(".btn-submit").icon_spin(start:true)
1132
pdf = undefined
1133
async.series([
1134
(cb) =>
1135
@save(cb)
1136
(cb) =>
1137
# get info from the UI and attempt to convert the sagews to pdf
1138
options =
1139
title : dialog.find(".webapp-file-print-title").text()
1140
author : dialog.find(".webapp-file-print-author").text()
1141
date : dialog.find(".webapp-file-print-date").text()
1142
contents : dialog.find(".webapp-file-print-contents").is(":checked")
1143
subdir : is_subdir
1144
base_url : require('./misc').BASE_URL # really is a base url (not base path)
1145
extra_data : misc.to_json(@syncdoc.print_to_pdf_data()) # avoid de/re-json'ing
1146
1147
printing.Printer(@, @filename + '.pdf').print
1148
project_id : @project_id
1149
path : @filename
1150
options : options
1151
cb : (err, _pdf) =>
1152
if err and not is_subdir
1153
cb(err)
1154
else
1155
pdf = _pdf
1156
cb()
1157
(cb) =>
1158
file_nonzero_size_cb(@project_id, pdf, cb)
1159
(cb) =>
1160
if is_subdir or not pdf?
1161
cb(); return
1162
# pdf file exists -- show it in the UI
1163
url = webapp_client.project_client.read_file
1164
project_id : @project_id
1165
path : pdf
1166
dialog.find(".webapp-file-printing-link").attr('href', url).text(pdf).show()
1167
cb()
1168
(cb) =>
1169
if not is_subdir
1170
cb(); return
1171
{join} = require('path')
1172
subdir_texfile = join(p.head, "#{base}-sagews2pdf", "tmp.tex")
1173
# check if generated tmp.tex exists and has nonzero size
1174
file_nonzero_size_cb @project_id, subdir_texfile, (err) =>
1175
if err
1176
cb("Unable to create directory of temporary Latex files. -- #{err}")
1177
return
1178
tempdir_link = $('<a>').text('Click to open temporary file')
1179
tempdir_link.click =>
1180
redux.getProjectActions(@project_id).open_file
1181
path : subdir_texfile
1182
foreground : true
1183
dialog.modal('hide')
1184
return false
1185
$print_tempdir.html(tempdir_link)
1186
$print_tempdir.show()
1187
cb()
1188
(cb) =>
1189
# if there is no subdirectory of temporary files, print generated pdf file
1190
if not is_subdir
1191
redux.getProjectActions(@project_id).print_file(path: pdf)
1192
cb()
1193
], (err) =>
1194
dialog.find(".btn-submit").icon_spin(false)
1195
dialog.find(".webapp-file-printing-progress").hide()
1196
if err
1197
alert_message(type:"error", message:"problem printing '#{p.tail}' -- #{misc.to_json(err)}")
1198
)
1199
return false
1200
1201
dialog.find(".webapp-file-print-filename").text(@filename)
1202
dialog.find(".webapp-file-print-title").text(base)
1203
dialog.find(".webapp-file-print-author").text(redux.getStore('account').get_fullname())
1204
dialog.find(".webapp-file-print-date").text((new Date()).toLocaleDateString())
1205
dialog.find(".btn-submit").click(submit)
1206
dialog.find(".btn-close").click(() -> dialog.modal('hide'); return false)
1207
if ext == "sagews"
1208
dialog.find(".webapp-file-options-sagews").show()
1209
dialog.modal('show')
1210
1211
init_save_button: () =>
1212
@save_button = @element.find("a[href=\"#save\"]").tooltip().click(@click_save_button)
1213
@save_button.find(".spinner").hide()
1214
1215
init_uncommitted_element: () =>
1216
@uncommitted_element = @element.find(".smc-uncommitted")
1217
1218
init_history_button: () =>
1219
if not @opts.public_access and @filename.slice(@filename.length-13) != '.sage-history'
1220
@history_button = @element.find(".webapp-editor-history-button")
1221
@history_button.click(@click_history_button)
1222
@history_button.show()
1223
@history_button.css
1224
display: 'inline-block' # this is needed due to subtleties of jQuery show().
1225
1226
click_save_button: () =>
1227
if @opts.read_only
1228
return
1229
if not @save? # not implemented...
1230
return
1231
if @_saving
1232
return
1233
@_saving = true
1234
@syncdoc?.delete_trailing_whitespace?() # only delete trailing whitespace on explicit save -- never on AUTOSAVE.
1235
@save_button.icon_spin(start:true, delay:8000)
1236
@save (err) =>
1237
# WARNING: As far as I can tell, this doesn't call FileEditor.save
1238
if err
1239
if redux.getProjectStore(@project_id).is_file_open(@filename) # only show error if file actually opened
1240
alert_message(type:"error", message:"Error saving '#{@filename}' (#{err}) -- (you might need to close and open this file or restart this project)")
1241
else
1242
@emit('saved')
1243
@save_button.icon_spin(false)
1244
@_saving = false
1245
return false
1246
1247
click_history_button: () =>
1248
redux.getProjectActions(@project_id).open_file
1249
path : misc.history_path(@filename)
1250
foreground : true
1251
1252
_get: () =>
1253
return @codemirror?.getValue()
1254
1255
_set: (content) =>
1256
if not @codemirror?
1257
# document is already closed and freed up.
1258
return
1259
{from} = @codemirror.getViewport()
1260
@codemirror.setValue(content)
1261
@codemirror.scrollIntoView(from)
1262
# even better -- fully restore cursors, if available in localStorage
1263
setTimeout((()=>@restore_cursor_position()),1) # do in next round, so that both editors get set by codemirror first (including the linked one)
1264
1265
# save/restore view state -- hooks used by React editor wrapper.
1266
save_view_state: =>
1267
state =
1268
scroll : (cm.getScrollInfo() for cm in @codemirrors())
1269
@_view_state = state
1270
return state
1271
1272
restore_view_state: (second_try) =>
1273
state = @_view_state
1274
if not state?
1275
return
1276
cms = @codemirrors()
1277
i = 0
1278
for v in state.scroll
1279
cm = cms[i]
1280
if cm?
1281
cm.scrollTo(v.left, v.top)
1282
info = cm.getScrollInfo()
1283
# THIS IS HORRIBLE and SUCKS, but I can't understand what is going on sufficiently
1284
# well to remove this. Sometimes scrollTo fails (due to the document being reported as much
1285
# smaller than it is for a few ms) **and** it's then not possible to scroll,
1286
# so we just try again. See https://github.com/sagemathinc/cocalc/issues/1327
1287
if not second_try and info.top != v.top
1288
# didn't work -- not fully visible; try again one time when rendering is presumably done.
1289
setTimeout((=>@restore_view_state(true)), 250)
1290
i += 1
1291
1292
restore_cursor_position: () =>
1293
for i, cm of @codemirrors()
1294
if cm?
1295
pos = @local_storage("cursor#{cm.name}")
1296
if pos?
1297
cm.setCursor(pos)
1298
#console.log("#{@filename}: setting view #{cm.name} to cursor pos -- #{misc.to_json(pos)}")
1299
info = cm.getScrollInfo()
1300
try
1301
cm.scrollIntoView(pos, info.clientHeight/2)
1302
catch e
1303
#console.log("#{@filename}: failed to scroll view #{cm.name} into view -- #{e}")
1304
@codemirror?.focus()
1305
1306
# set background color of active line in editor based on background color (which depends on the theme)
1307
_style_active_line: () =>
1308
if not @opts.style_active_line
1309
return
1310
rgb = $(@codemirror.getWrapperElement()).css('background-color')
1311
v = (parseInt(x) for x in rgb.slice(4,rgb.length-1).split(','))
1312
amount = @opts.style_active_line
1313
for i in [0..2]
1314
if v[i] >= 128
1315
v[i] -= amount
1316
else
1317
v[i] += amount
1318
$("body").remove("#webapp-cm-activeline")
1319
$("body").append("<style id='webapp-cm-activeline' type=text/css>.CodeMirror-activeline{background:rgb(#{v[0]},#{v[1]},#{v[2]});}</style>") # this is a memory leak!
1320
1321
_show_codemirror_editors: (height) =>
1322
# console.log("_show_codemirror_editors: #{@_layout}")
1323
if not @codemirror?
1324
# already closed so can't show (in syncdoc, .codemirorr is deleted on close)
1325
return
1326
switch @_layout
1327
when 0
1328
p = 1
1329
when 1
1330
p = @_layout1_split_pos ? 0.5
1331
when 2
1332
p = @_layout2_split_pos ? 0.5
1333
1334
# Change the height of the *top* div that contain the editors; the bottom one then
1335
# uses of all remaining vertical height.
1336
if @_layout > 0
1337
p = Math.max(MIN_SPLIT, Math.min(MAX_SPLIT, p))
1338
1339
# We set only the default size of the *first* div -- everything else expands accordingly.
1340
elt = @element.find(".webapp-editor-codemirror-input-container-layout-#{@_layout}").show()
1341
1342
if @_layout == 1
1343
@element.find(".webapp-editor-resize-bar-layout-1").css(top:0)
1344
else if @_layout == 2
1345
@element.find(".webapp-editor-resize-bar-layout-2").css(left:0)
1346
1347
c = elt.find(".webapp-editor-codemirror-input-box")
1348
if @_layout == 0
1349
c.css('flex', 1) # use the full vertical height
1350
else
1351
c.css('flex-basis', "#{p*100}%")
1352
1353
if @_last_layout != @_layout
1354
# The layout has changed
1355
btn = @element.find('a[href="#split-view"]')
1356
1357
if @_last_layout?
1358
# Hide previous
1359
btn.find(".webapp-editor-layout-#{@_last_layout}").hide()
1360
@element.find(".webapp-editor-codemirror-input-container-layout-#{@_last_layout}").hide()
1361
1362
# Show current
1363
btn.find(".webapp-editor-layout-#{@_layout}").show()
1364
1365
# Put editors in their place -- in the div inside of each box
1366
elt.find(".webapp-editor-codemirror-input-box div").empty().append($(@codemirror.getWrapperElement()))
1367
elt.find(".webapp-editor-codemirror-input-box-1 div").empty().append($(@codemirror1.getWrapperElement()))
1368
1369
# Save for next time
1370
@_last_layout = @_layout
1371
1372
refresh = (cm) =>
1373
return if not cm?
1374
cm_refresh(cm)
1375
# See https://github.com/sagemathinc/cocalc/issues/1327#issuecomment-265488872
1376
setTimeout((=>cm_refresh(cm)), 1)
1377
1378
for cm in @codemirrors()
1379
refresh(cm)
1380
1381
@emit('show')
1382
1383
_show: (opts={}) =>
1384
# show the element that contains this editor
1385
#@element.show()
1386
# show the codemirror editors, resizing as needed
1387
@_show_codemirror_editors()
1388
1389
focus: () =>
1390
if not @codemirror?
1391
return
1392
@show()
1393
if not (IS_MOBILE or feature.IS_TOUCH)
1394
@codemirror_with_last_focus?.focus()
1395
1396
############
1397
# Editor button bar support code
1398
############
1399
textedit_command: (cm, cmd, args) =>
1400
# ATTN when adding more cases, also edit textedit_only_show_known_buttons
1401
switch cmd
1402
when "link"
1403
cm.insert_link(cb:() => @syncdoc?.sync())
1404
return false # don't return true or get an infinite recurse
1405
when "image"
1406
cm.insert_image(cb:() => @syncdoc?.sync())
1407
return false # don't return true or get an infinite recurse
1408
when "SpecialChar"
1409
cm.insert_special_char(cb:() => @syncdoc?.sync())
1410
return false # don't return true or get an infinite recurse
1411
else
1412
cm.edit_selection
1413
cmd : cmd
1414
args : args
1415
@syncdoc?.sync()
1416
# needed so that dropdown menu closes when clicked.
1417
return true
1418
1419
example_insert_handler: (insert) =>
1420
# insert : {lang: string, descr: string, code: string[]}
1421
{code, lang} = insert
1422
cm = @focused_codemirror()
1423
line = cm.getCursor().line
1424
# ATTN: to make this work properly, code and descr need to have a newline at the end (stripped by default)
1425
if insert.descr?
1426
@syncdoc?.insert_new_cell(line)
1427
# insert a "hidden" markdown cell and evaluate it
1428
cm.replaceRange("%md(hide=True)\n#{insert.descr}\n", {line : line+1, ch:0})
1429
@action_key(execute: true, advance:false, split:false)
1430
1431
# inserting one or more code cells
1432
for c in code
1433
line = cm.getCursor().line
1434
# next, we insert the code cell and prefix it with a mode change,
1435
# iff the mode is different from the current one
1436
@syncdoc?.insert_new_cell(line)
1437
cell = "#{c}\n"
1438
if lang != @_current_mode
1439
# special case: %sh for bash language
1440
if lang == 'bash' then lang = 'sh'
1441
cell = "%#{lang}\n#{cell}"
1442
cm.replaceRange(cell, {line : line+1, ch:0})
1443
# and we evaluate and sync all this, too…
1444
@action_key(execute: true, advance:false, split:false)
1445
@syncdoc?.sync()
1446
1447
# add a textedit toolbar to the editor
1448
init_sagews_edit_buttons: () =>
1449
if @opts.read_only # no editing button bar needed for read-only files
1450
return
1451
1452
if IS_MOBILE # no edit button bar on mobile either -- too big (for now at least)
1453
return
1454
1455
if not redux.getStore('account').get_editor_settings().extra_button_bar
1456
# explicitly disabled by user
1457
return
1458
1459
NAME_TO_MODE = {xml:'html', markdown:'md', mediawiki:'wiki'}
1460
for x in sagews_decorator_modes
1461
mode = x[0]
1462
name = x[1]
1463
v = name.split('-')
1464
if v.length > 1
1465
name = v[1]
1466
NAME_TO_MODE[name] = "#{mode}"
1467
1468
name_to_mode = (name) ->
1469
n = NAME_TO_MODE[name]
1470
if n?
1471
return n
1472
else
1473
return "#{name}"
1474
1475
# add the text editing button bar
1476
e = @element.find(".webapp-editor-codemirror-textedit-buttons")
1477
@textedit_buttons = templates.find(".webapp-editor-textedit-buttonbar").clone().hide()
1478
e.append(@textedit_buttons).show()
1479
1480
# add the code editing button bar
1481
@codeedit_buttons = templates.find(".webapp-editor-codeedit-buttonbar").clone()
1482
e.append(@codeedit_buttons)
1483
1484
# the r-editing button bar
1485
@redit_buttons = templates.find(".webapp-editor-redit-buttonbar").clone()
1486
e.append(@redit_buttons)
1487
1488
# the Julia-editing button bar
1489
@julia_edit_buttons = templates.find(".webapp-editor-julia-edit-buttonbar").clone()
1490
e.append(@julia_edit_buttons)
1491
1492
# the sh-editing button bar
1493
@sh_edit_buttons = templates.find(".webapp-editor-sh-edit-buttonbar").clone()
1494
e.append(@sh_edit_buttons)
1495
1496
@cython_buttons = templates.find(".webapp-editor-cython-buttonbar").clone()
1497
e.append(@cython_buttons)
1498
1499
@fallback_buttons = templates.find(".webapp-editor-fallback-edit-buttonbar").clone()
1500
e.append(@fallback_buttons)
1501
1502
all_edit_buttons = [@textedit_buttons, @codeedit_buttons, @redit_buttons,
1503
@cython_buttons, @julia_edit_buttons, @sh_edit_buttons, @fallback_buttons]
1504
1505
# activite the buttons in the bar
1506
that = @
1507
edit_button_click = (e) ->
1508
e.preventDefault()
1509
args = $(this).data('args')
1510
cmd = $(this).attr('href').slice(1)
1511
if cmd == 'todo'
1512
return
1513
if args? and typeof(args) != 'object'
1514
args = "#{args}"
1515
if args.indexOf(',') != -1
1516
args = args.split(',')
1517
return that.textedit_command(that.focused_codemirror(), cmd, args)
1518
1519
# FUTURE: activate color editing buttons -- for now just hide them
1520
@element.find(".sagews-output-editor-foreground-color-selector").hide()
1521
@element.find(".sagews-output-editor-background-color-selector").hide()
1522
1523
@fallback_buttons.find('a[href="#todo"]').click () =>
1524
bootbox.alert("<i class='fa fa-wrench' style='font-size: 18pt;margin-right: 1em;'></i> Button bar not yet implemented in <code>#{mode_display.text()}</code> cells.")
1525
return false
1526
1527
for edit_buttons in all_edit_buttons
1528
edit_buttons.find("a").click(edit_button_click)
1529
edit_buttons.find("*[title]").tooltip(TOOLTIP_DELAY)
1530
1531
@mode_display = mode_display = @element.find(".webapp-editor-codeedit-buttonbar-mode")
1532
@_current_mode = "sage"
1533
@mode_display.show()
1534
1535
# not all textedit buttons are known
1536
textedit_only_show_known_buttons = (name) =>
1537
EDIT_COMMANDS = require('./editors/editor-button-bar').commands
1538
default_mode = @focused_codemirror()?.get_edit_mode() ? 'sage'
1539
mode = sagews_canonical_mode(name, default_mode)
1540
#if DEBUG then console.log "textedit_only_show_known_buttons: mode #{name} → #{mode}"
1541
known_commands = misc.keys(EDIT_COMMANDS[mode] ? {})
1542
# see special cases in 'textedit_command' and codemirror/extensions: 'edit_selection'
1543
known_commands = known_commands.concat(['link', 'image', 'SpecialChar', 'font_size'])
1544
for button in @textedit_buttons.find('a')
1545
button = $(button)
1546
cmd = button.attr('href').slice(1)
1547
# in theory, this should also be done for html&md, but there are many more special cases
1548
# therefore we just make sure they're all activated again
1549
button.toggle((mode != 'tex') or (cmd in known_commands))
1550
1551
set_mode_display = (name) =>
1552
#console.log("set_mode_display: #{name}")
1553
if name?
1554
mode = name_to_mode(name)
1555
else
1556
mode = ""
1557
mode_display.text("%" + mode)
1558
@_current_mode = mode
1559
1560
show_edit_buttons = (which_one, name) =>
1561
for edit_buttons in all_edit_buttons
1562
edit_buttons.toggle(edit_buttons == which_one)
1563
if which_one == @textedit_buttons
1564
textedit_only_show_known_buttons(name)
1565
set_mode_display(name)
1566
1567
# this is deprecated
1568
@element.find('.webapp-editor-codeedit-buttonbar-assistant').hide()
1569
1570
# The code below changes the bar at the top depending on where the cursor
1571
# is located. We only change the edit bar if the cursor hasn't moved for
1572
# a while, to be more efficient, avoid noise, and be less annoying to the user.
1573
# Replaced by http://underscorejs.org/#debounce
1574
#bar_timeout = undefined
1575
#f = () =>
1576
# if bar_timeout?
1577
# clearTimeout(bar_timeout)
1578
# bar_timeout = setTimeout(update_context_sensitive_bar, 250)
1579
1580
update_context_sensitive_bar = () =>
1581
cm = @focused_codemirror()
1582
if not cm?
1583
return
1584
pos = cm.getCursor()
1585
name = cm.getModeAt(pos).name
1586
#console.log("update_context_sensitive_bar, pos=#{misc.to_json(pos)}, name=#{name}")
1587
if name in ['xml', 'stex', 'markdown', 'mediawiki']
1588
show_edit_buttons(@textedit_buttons, name)
1589
else if name == "r"
1590
show_edit_buttons(@redit_buttons, name)
1591
else if name == "julia"
1592
show_edit_buttons(@julia_edit_buttons, name)
1593
else if name == "cython" # doesn't work yet, since name=python still
1594
show_edit_buttons(@cython_buttons, name)
1595
else if name == "python" # doesn't work yet, since name=python still
1596
show_edit_buttons(@codeedit_buttons, "sage")
1597
else if name == "shell"
1598
show_edit_buttons(@sh_edit_buttons, name)
1599
else
1600
show_edit_buttons(@fallback_buttons, name)
1601
1602
for cm in @codemirrors()
1603
cm.on('cursorActivity', _.debounce(update_context_sensitive_bar, 250))
1604
1605
update_context_sensitive_bar()
1606
@element.find(".webapp-editor-codemirror-textedit-buttons").katex({preProcess:true})
1607
1608
1609
exports.codemirror_editor = (project_id, filename, extra_opts) ->
1610
return new CodeMirrorEditor(project_id, filename, "", extra_opts)
1611
1612
codemirror_session_editor = exports.codemirror_session_editor = (project_id, filename, extra_opts) ->
1613
#console.log("codemirror_session_editor '#{filename}'")
1614
ext = filename_extension_notilde(filename).toLowerCase()
1615
1616
E = new CodeMirrorEditor(project_id, filename, "", extra_opts)
1617
# Enhance the editor with synchronized session capabilities.
1618
opts =
1619
cursor_interval : E.opts.cursor_interval
1620
sync_interval : E.opts.sync_interval
1621
1622
switch ext
1623
when "sagews"
1624
# temporary.
1625
opts =
1626
cursor_interval : 2000
1627
sync_interval : 250
1628
E.syncdoc = new (sagews.SynchronizedWorksheet)(E, opts)
1629
E.action_key = E.syncdoc.action
1630
E.interrupt_key = E.syncdoc.interrupt
1631
E.tab_nothing_selected = () => E.syncdoc.introspect()
1632
when "sage-history"
1633
# no syncdoc
1634
else
1635
E.syncdoc = new (syncdoc.SynchronizedDocument2)(E, opts)
1636
1637
E.save = E.syncdoc?.save
1638
return E
1639
1640
class Terminal extends FileEditor
1641
constructor: (project_id, filename, content, opts) ->
1642
super(project_id, filename)
1643
@element = $("<div>").hide()
1644
elt = @element.webapp_console
1645
title : "Terminal"
1646
filename : @filename
1647
project_id : @project_id
1648
path : @filename
1649
editor : @
1650
@console = elt.data("console")
1651
@console.is_hidden = true
1652
@element = @console.element
1653
@console.blur()
1654
1655
_get: => # FUTURE ??
1656
return @opts.session_uuid ? ''
1657
1658
_set: (content) => # FUTURE ??
1659
1660
save: =>
1661
# DO nothing -- a no-op for now
1662
# FUTURE: Add notion of history
1663
cb?()
1664
1665
focus: =>
1666
@console?.is_hidden = false
1667
@console?.focus()
1668
1669
blur: =>
1670
@console?.is_hidden = false
1671
@console?.blur()
1672
1673
terminate_session: () =>
1674
1675
remove: =>
1676
@element.webapp_console(false)
1677
super()
1678
1679
hide: =>
1680
@console?.is_hidden = true
1681
@console?.blur()
1682
1683
_show: () =>
1684
@console?.is_hidden = false
1685
@console?.resize_terminal()
1686
1687
1688
1689
class FileEditorWrapper extends FileEditor
1690
constructor: (project_id, filename, content, opts) ->
1691
super(project_id, filename)
1692
@content = content
1693
@opts = opts
1694
@init_wrapped(@project_id, @filename, @content, @opts)
1695
1696
init_wrapped: () =>
1697
# Define @element and @wrapped in derived class
1698
throw Error('must define in derived class')
1699
1700
save: (cb) =>
1701
if @wrapped?.save?
1702
@wrapped.save(cb)
1703
else
1704
cb?()
1705
1706
has_unsaved_changes: (val) =>
1707
return @wrapped?.has_unsaved_changes?(val)
1708
1709
has_uncommitted_changes: (val) =>
1710
return @wrapped?.has_uncommitted_changes?(val)
1711
1712
_get: () =>
1713
# FUTURE
1714
return 'history saving not yet implemented'
1715
1716
_set: (content) =>
1717
# FUTURE ???
1718
1719
focus: () =>
1720
1721
terminate_session: () =>
1722
1723
disconnect_from_session: () =>
1724
@wrapped?.destroy?()
1725
1726
remove: () =>
1727
super()
1728
@wrapped?.destroy?()
1729
delete @filename; delete @content; delete @opts
1730
1731
show: () =>
1732
if not @is_active()
1733
return
1734
if not @element?
1735
return
1736
@element.show()
1737
1738
if IS_MOBILE
1739
@element.css(position:'relative')
1740
1741
@wrapped?.show?()
1742
1743
hide: () =>
1744
@element?.hide()
1745
@wrapped?.hide?()
1746
1747
1748
1749
exports.register_nonreact_editors = ->
1750
1751
# Make non-react editors available in react rewrite
1752
reg = require('./editors/react-wrapper').register_nonreact_editor
1753
1754
# wrapper for registering private and public editors
1755
exports.register = register = (is_public, cls, extensions) ->
1756
icon = file_icon_class(extensions[0])
1757
reg
1758
ext : extensions
1759
is_public : is_public
1760
icon : icon
1761
f : (project_id, path, opts) ->
1762
e = new cls(project_id, path, undefined, opts)
1763
if not e.ext?
1764
console.error('You have to call super(@project_id, @filename) in the constructor to properly initialize this FileEditor instance.')
1765
return e
1766
1767
if feature.IS_TOUCH
1768
register(false, Terminal, ['term', 'sage-term'])
1769
1770
# Editing Sage worksheets
1771
reg
1772
ext : 'sagews'
1773
f : (project_id, path, opts) -> codemirror_session_editor(project_id, path, opts)
1774
is_public : false
1775
1776
1777
1778
# See https://github.com/sagemathinc/cocalc/issues/3538
1779
cm_refresh = (cm) ->
1780
if not cm?
1781
return
1782
try
1783
cm.refresh()
1784
catch err
1785
console.warn("cm refresh err", err)
1786
1787
1788