Skip to content

Commit e0fce9a

Browse files
author
Ives van Hoorne
committed
User selections
1 parent f23d843 commit e0fce9a

File tree

9 files changed

+370
-36
lines changed

9 files changed

+370
-36
lines changed

packages/app/src/app/components/CodeEditor/Monaco/index.js

Lines changed: 220 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as React from 'react';
33
import { TextOperation } from 'ot';
44
import { debounce } from 'lodash';
55
import { getModulePath } from 'common/sandbox/modules';
6+
import { css } from 'glamor';
67

78
import getTemplate from 'common/templates';
89
import type {
@@ -48,6 +49,50 @@ function indexToLineAndColumn(lines, index) {
4849
return { lineNumber: lines.length, column: lines[lines.length - 1] + 2 };
4950
}
5051

52+
const fadeIn = css.keyframes('fadeIn', {
53+
// optional name
54+
'0%': { opacity: 0 },
55+
'100%': { opacity: 1 },
56+
});
57+
58+
function lineAndColumnToIndex(lines, lineNumber, column) {
59+
let currentLine = 0;
60+
let index = 0;
61+
62+
while (currentLine + 1 < lineNumber) {
63+
index += lines[currentLine].length;
64+
index += 1; // Linebreak character
65+
currentLine += 1;
66+
}
67+
68+
index += column - 1;
69+
70+
return index;
71+
}
72+
73+
function getSelection(lines, selection) {
74+
const startSelection = lineAndColumnToIndex(
75+
lines,
76+
selection.startLineNumber,
77+
selection.startColumn
78+
);
79+
const endSelection = lineAndColumnToIndex(
80+
lines,
81+
selection.endLineNumber,
82+
selection.endColumn
83+
);
84+
85+
return {
86+
selection:
87+
startSelection === endSelection ? [] : [startSelection, endSelection],
88+
cursorPosition: lineAndColumnToIndex(
89+
lines,
90+
selection.positionLineNumber,
91+
selection.positionColumn
92+
),
93+
};
94+
}
95+
5196
let modelCache = {};
5297

5398
const fontFamilies = (...families) =>
@@ -232,6 +277,32 @@ class MonacoEditor extends React.Component<Props, State> implements Editor {
232277
}
233278
});
234279

280+
editor.onDidChangeCursorSelection(selectionChange => {
281+
const { onSelectionChanged, isLive } = this.props;
282+
// Reason 3 is update by mouse or arrow keys
283+
if (
284+
isLive &&
285+
(selectionChange.reason === 3 ||
286+
/* alt + shift + arrow keys */ selectionChange.source ===
287+
'moveWordCommand' ||
288+
/* click inside a selection */ selectionChange.source === 'api') &&
289+
onSelectionChanged
290+
) {
291+
const lines = editor.getModel().getLinesContent();
292+
const data = {
293+
primary: getSelection(lines, selectionChange.selection),
294+
secondary: selectionChange.secondarySelections.map(s =>
295+
getSelection(lines, s)
296+
),
297+
};
298+
299+
onSelectionChanged({
300+
selection: data,
301+
moduleShortid: this.currentModule.shortid,
302+
});
303+
}
304+
});
305+
235306
if (this.props.onInitialized) {
236307
this.disposeInitializer = this.props.onInitialized(this);
237308
}
@@ -350,17 +421,12 @@ class MonacoEditor extends React.Component<Props, State> implements Editor {
350421
.map(change => {
351422
const startPos = change.range.getStartPosition();
352423
const lines = code.split('\n');
353-
let index = 0;
354424
const totalLength = code.length;
355-
let currentLine = 0;
356-
357-
while (currentLine + 1 < startPos.lineNumber) {
358-
index += lines[currentLine].length;
359-
index += 1; // Linebreak character
360-
currentLine += 1;
361-
}
362-
363-
index += startPos.column - 1;
425+
let index = lineAndColumnToIndex(
426+
lines,
427+
startPos.lineNumber,
428+
startPos.column
429+
);
364430

365431
const operation = new TextOperation();
366432
if (index) {
@@ -395,6 +461,149 @@ class MonacoEditor extends React.Component<Props, State> implements Editor {
395461
this.changes = { code: '', changes: [] };
396462
};
397463

464+
userClassesGenerated = {};
465+
userSelectionDecorations = {};
466+
userSelections = [];
467+
updateUserSelections = (
468+
userSelections: Array<{
469+
userId: string,
470+
name: string,
471+
selection: any,
472+
color: Array<number>,
473+
}>
474+
) => {
475+
this.userSelections = userSelections;
476+
const lines = this.editor.getModel().getLinesContent();
477+
478+
userSelections.forEach(data => {
479+
const decorations = [];
480+
const { selection, color, userId, name } = data;
481+
482+
if (selection) {
483+
const addCursor = (position, className) => {
484+
const cursorPos = indexToLineAndColumn(lines, position);
485+
486+
decorations.push({
487+
range: new this.monaco.Range(
488+
cursorPos.lineNumber,
489+
cursorPos.column,
490+
cursorPos.lineNumber,
491+
cursorPos.column
492+
),
493+
options: {
494+
className: this.userClassesGenerated[className],
495+
},
496+
});
497+
};
498+
499+
const addSelection = (start, end, className) => {
500+
const from = indexToLineAndColumn(lines, start);
501+
const to = indexToLineAndColumn(lines, end);
502+
503+
decorations.push({
504+
range: new this.monaco.Range(
505+
from.lineNumber,
506+
from.column,
507+
to.lineNumber,
508+
to.column
509+
),
510+
options: {
511+
className: this.userClassesGenerated[className],
512+
},
513+
});
514+
};
515+
516+
const cursorClassName = userId + '-cursor';
517+
const secondaryCursorClassName = userId + '-secondary-cursor';
518+
const selectionClassName = userId + '-selection';
519+
const secondarySelectionClassName = userId + '-secondary-selection';
520+
521+
if (!this.userClassesGenerated[cursorClassName]) {
522+
this.userClassesGenerated[cursorClassName] = `${css({
523+
backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.8)`,
524+
width: '2px !important',
525+
cursor: 'text',
526+
zIndex: 30,
527+
':hover': {
528+
':before': {
529+
animation: `${fadeIn} 0.3s`,
530+
animationFillMode: 'forwards',
531+
opacity: 0,
532+
content: name,
533+
position: 'absolute',
534+
top: -20,
535+
backgroundColor: `rgb(${color[0]}, ${color[1]}, ${color[2]})`,
536+
zIndex: 20,
537+
color:
538+
color[0] + color[1] + color[2] > 500
539+
? 'rgba(0, 0, 0, 0.8)'
540+
: 'white',
541+
padding: '2px 6px',
542+
borderRadius: 2,
543+
borderBottomLeftRadius: 0,
544+
fontSize: '.875rem',
545+
fontWeight: 800,
546+
},
547+
},
548+
})}`;
549+
}
550+
551+
if (!this.userClassesGenerated[secondaryCursorClassName]) {
552+
this.userClassesGenerated[secondaryCursorClassName] = `${css({
553+
backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.6)`,
554+
width: '2px !important',
555+
})}`;
556+
}
557+
558+
if (!this.userClassesGenerated[selectionClassName]) {
559+
this.userClassesGenerated[selectionClassName] = `${css({
560+
backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.3)`,
561+
borderRadius: '3px',
562+
})}`;
563+
}
564+
565+
if (!this.userClassesGenerated[secondarySelectionClassName]) {
566+
this.userClassesGenerated[secondarySelectionClassName] = `${css({
567+
backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.2)`,
568+
borderRadius: '3px',
569+
})}`;
570+
}
571+
572+
addCursor(selection.primary.cursorPosition, cursorClassName);
573+
if (selection.primary.selection.length) {
574+
addSelection(
575+
selection.primary.selection[0],
576+
selection.primary.selection[1],
577+
selectionClassName
578+
);
579+
}
580+
581+
if (selection.secondary.length) {
582+
selection.secondary.forEach(s => {
583+
addCursor(s.cursorPosition, secondaryCursorClassName);
584+
585+
if (s.selection.length) {
586+
addSelection(
587+
s.selection[0],
588+
s.selection[1],
589+
secondarySelectionClassName
590+
);
591+
}
592+
});
593+
}
594+
}
595+
596+
this.userSelectionDecorations[
597+
this.currentModule.shortid + userId
598+
] = this.editor.deltaDecorations(
599+
this.userSelectionDecorations[this.currentModule.shortid + userId] ||
600+
[],
601+
decorations,
602+
userId
603+
);
604+
});
605+
};
606+
398607
changeSandbox = (
399608
newSandbox: Sandbox,
400609
newCurrentModule: Module,
@@ -458,6 +667,7 @@ class MonacoEditor extends React.Component<Props, State> implements Editor {
458667
column
459668
),
460669
text: op,
670+
forceMoveMarkers: true,
461671
},
462672
]);
463673
index += op.length;

packages/app/src/app/components/CodeEditor/types.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export interface Editor {
4040
setTSConfig?: (tsConfig: Object) => void;
4141
setReceivingCode?: (receivingCode: boolean) => void;
4242
applyOperation?: (operation: any) => void;
43+
updateUserSelections?: (selections: any) => void;
4344
}
4445

4546
export type Props = {
@@ -62,4 +63,5 @@ export type Props = {
6263
sendTransforms?: (transform: any) => void,
6364
receivingCode?: boolean,
6465
onCodeReceived?: () => void,
66+
onSelectionChanged: (d: { selection: any, moduleShortid: string }) => void,
6567
};

packages/app/src/app/pages/Sandbox/Editor/Content/index.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import * as React from 'react';
33
import { ThemeProvider } from 'styled-components';
44
import { Prompt } from 'react-router-dom';
5-
import { reaction } from 'mobx';
5+
import { reaction, autorun } from 'mobx';
66
import { TextOperation } from 'ot';
77
import { inject, observer } from 'mobx-react';
88
import getTemplateDefinition from 'common/templates';
@@ -228,6 +228,14 @@ class EditorPreview extends React.Component<Props, State> {
228228
}
229229
);
230230

231+
const disposeLiveSelectionHandler = autorun(() => {
232+
if (store.editor.pendingUserSelections) {
233+
if (editor.updateUserSelections) {
234+
editor.updateUserSelections(store.editor.pendingUserSelections);
235+
}
236+
}
237+
});
238+
231239
const disposeModuleHandler = reaction(
232240
() => [store.editor.currentModule, store.editor.currentModule.code],
233241
([newModule]) => {
@@ -267,6 +275,7 @@ class EditorPreview extends React.Component<Props, State> {
267275
disposeGlyphsHandler();
268276
disposeLiveHandler();
269277
disposePendingOperationHandler();
278+
disposeLiveSelectionHandler();
270279
};
271280
};
272281

@@ -374,6 +383,7 @@ class EditorPreview extends React.Component<Props, State> {
374383
}
375384
isLive={store.live.isLive}
376385
onCodeReceived={signals.live.onCodeReceived}
386+
onSelectionChanged={signals.live.onSelectionChanged}
377387
onNpmDependencyAdded={name => {
378388
if (sandbox.owned) {
379389
signals.editor.addNpmDependency({ name, isDev: true });

packages/app/src/app/store/modules/editor/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export default Module({
3131
glyphs: [],
3232
corrections: [],
3333
pendingOperation: null,
34+
pendingUserSelections: [],
3435
isInProjectView: false,
3536
forceRender: 0,
3637
initialPath: '/',

packages/app/src/app/store/modules/editor/model.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { types } from 'mobx-state-tree';
2+
import { UserSelection } from '../live/model';
23

34
const Author = types.model({
45
avatarUrl: types.string,
@@ -100,6 +101,14 @@ export default {
100101
pendingOperation: types.maybe(
101102
types.array(types.union(types.string, types.number))
102103
),
104+
pendingUserSelections: types.array(
105+
types.model({
106+
userId: types.string,
107+
name: types.string,
108+
selection: UserSelection,
109+
color: types.array(types.number),
110+
})
111+
),
103112
tabs: types.array(
104113
types.model({
105114
type: types.string,

0 commit comments

Comments
 (0)