Skip to content

Commit 818df11

Browse files
committed
cells can be selected using CMD+UP
1 parent 0f5e4a4 commit 818df11

File tree

10 files changed

+233
-41
lines changed

10 files changed

+233
-41
lines changed

editor-runtime/src/commonMain/kotlin/org/modelix/editor/CaretSelection.kt

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,6 @@ class CaretSelection(val layoutable: LayoutableCell, val start: Int, val end: In
2929
return CaretSelection(newLayoutable, start.coerceAtMost(textLength), end.coerceAtMost(textLength))
3030
}
3131

32-
override fun <T> produceHtml(consumer: TagConsumer<T>) {
33-
consumer.div("caret own") {
34-
val textLength = layoutable.cell.getVisibleText()?.length ?: 0
35-
if (textLength == 0) {
36-
// A typical case is a StringLiteral editor for an empty string.
37-
// There is no space around the empty text cell.
38-
// 'leftend' or 'rightend' styles would look like the caret is set into one of the '"' cells.
39-
} else if (end == 0) {
40-
classes += "leftend"
41-
} else if (end == textLength) {
42-
classes += "rightend"
43-
}
44-
}
45-
}
46-
4732
override fun processKeyDown(event: JSKeyboardEvent): Boolean {
4833
val editor = getEditor() ?: throw IllegalStateException("Not attached to any editor")
4934
val knownKey = event.knownKey
@@ -77,8 +62,12 @@ class CaretSelection(val layoutable: LayoutableCell, val start: Int, val end: In
7762
?.let { editor.changeSelection(it) }
7863
}
7964
KnownKeys.ArrowUp -> {
80-
createNextPreviousLineSelection(false, desiredXPosition ?: getAbsoluteX())
81-
?.let { editor.changeSelection(it) }
65+
if (event.modifiers.meta) {
66+
layoutable.cell.let { editor.changeSelection(CellSelection(it)) }
67+
} else {
68+
createNextPreviousLineSelection(false, desiredXPosition ?: getAbsoluteX())
69+
?.let { editor.changeSelection(it) }
70+
}
8271
}
8372
KnownKeys.Tab -> {
8473
val target = layoutable

editor-runtime/src/commonMain/kotlin/org/modelix/editor/CellProperties.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ object CommonCellProperties {
4747
val backgroundColor = CellPropertyKey<String?>("background-color", null)
4848
val textReplacement = CellPropertyKey<String?>("text-replacement", null)
4949
val tabTarget = CellPropertyKey<Boolean>("tab-target", false) // caret is placed into the cell when navigating via TAB
50+
val selectable = CellPropertyKey<Boolean>("selectable", false)
5051
}
5152

5253
fun Cell.isTabTarget() = getProperty(CommonCellProperties.tabTarget)

editor-runtime/src/commonMain/kotlin/org/modelix/editor/EditorComponent.kt

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import org.modelix.incremental.IncrementalIndex
77
open class EditorComponent(
88
val engine: EditorEngine?,
99
private val rootCellCreator: (EditorState) -> Cell
10-
) : IProducesHtml {
10+
) {
1111
val state: EditorState = EditorState()
1212
private var selection: Selection? = null
1313
private val cellIndex: IncrementalIndex<CellReference, Cell> = IncrementalIndex()
@@ -81,22 +81,6 @@ open class EditorComponent(
8181
update()
8282
}
8383

84-
override fun isHtmlOutputValid(): Boolean = false
85-
86-
override fun <T> produceHtml(consumer: TagConsumer<T>) {
87-
consumer.div("editor") {
88-
div(MAIN_LAYER_CLASS_NAME) {
89-
produceChild(rootCell.layout)
90-
}
91-
div("selection-layer relative-layer") {
92-
produceChild(selection)
93-
}
94-
div("popup-layer relative-layer") {
95-
produceChild(codeCompletionMenu)
96-
}
97-
}
98-
}
99-
10084
fun dispose() {
10185

10286
}

editor-runtime/src/commonMain/kotlin/org/modelix/editor/EditorEngine.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ class EditorEngine(incrementalEngine: IncrementalEngine? = null) {
9898
data.cellReferences += NodeCellReference(node.untypedReference())
9999
data.properties[CellActionProperties.transformBefore] = SideTransformNode(true, node.untyped())
100100
data.properties[CellActionProperties.transformAfter] = SideTransformNode(false, node.untyped())
101+
data.properties[CommonCellProperties.selectable] = true
101102
return data
102103
} catch (ex: Exception) {
103104
LOG.error(ex) { "Failed to create cell for $node" }
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
package org.modelix.editor
15+
16+
import kotlinx.html.TagConsumer
17+
import kotlinx.html.div
18+
import kotlinx.html.span
19+
20+
data class CellSelection(val cell: Cell): Selection() {
21+
fun getEditor(): EditorComponent? = cell.editorComponent
22+
23+
override fun isValid(): Boolean {
24+
return getEditor() != null
25+
}
26+
27+
override fun update(editor: EditorComponent): Selection? {
28+
return cell.data.cellReferences.asSequence()
29+
.flatMap { editor.resolveCell(it) }
30+
.map { CellSelection(it) }
31+
.firstOrNull()
32+
}
33+
34+
override fun processKeyDown(event: JSKeyboardEvent): Boolean {
35+
val editor = getEditor() ?: throw IllegalStateException("Not attached to any editor")
36+
when (event.knownKey) {
37+
KnownKeys.ArrowUp -> {
38+
if (event.modifiers.meta) {
39+
cell.ancestors().firstOrNull { it.getProperty(CommonCellProperties.selectable) }
40+
?.let { editor.changeSelection(CellSelection(it)) }
41+
}
42+
}
43+
else -> {}
44+
}
45+
46+
return true
47+
}
48+
49+
fun getLayoutables(): List<Layoutable> {
50+
val editor = getEditor() ?: return emptyList()
51+
val rootText = editor.getRootCell().layout
52+
return cell.layout.lines.asSequence().flatMap { it.words }
53+
.filter { it.getLine()?.getText() === rootText }.toList()
54+
}
55+
}
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
package org.modelix.editor
22

3-
abstract class Selection : IProducesHtml, IKeyboardHandler {
3+
abstract class Selection : IKeyboardHandler {
44
abstract fun isValid(): Boolean
55
abstract fun update(editor: EditorComponent): Selection?
66
}
77

8-
abstract class SelectionView<E : Selection>(val selection: E) {
9-
abstract fun updateBounds()
8+
abstract class SelectionView<E : Selection>(val selection: E) : IProducesHtml {
9+
abstract fun update()
1010
}

editor-runtime/src/jsMain/kotlin/org/modelix/editor/DomUtils.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import org.w3c.dom.HTMLElement
66
import org.w3c.dom.Node
77
import org.w3c.dom.asList
88
import org.w3c.dom.events.MouseEvent
9+
import kotlin.math.max
10+
import kotlin.math.min
911

1012
data class Bounds(val x: Double, val y: Double, val width: Double, val height: Double) {
1113
fun maxX() = x + width
@@ -32,6 +34,19 @@ fun Bounds.relativeTo(origin: Bounds): Bounds {
3234
)
3335
}
3436

37+
fun Bounds?.union(other: Bounds?): Bounds? {
38+
return if (this == null) other else union(other)
39+
}
40+
41+
fun Bounds.union(other: Bounds?): Bounds {
42+
if (other == null) return this
43+
val minX = min(minX(), other.minX())
44+
val maxX = max(maxX(), other.maxX())
45+
val minY = min(minY(), other.minY())
46+
val maxY = max(maxY(), other.maxY())
47+
return Bounds(minX, minY, maxX - minX, maxY - minY)
48+
}
49+
3550
private fun getBodyAbsoluteBounds() = document.body?.getBoundingClientRect()?.toBounds() ?: ZERO_BOUNDS
3651
fun MouseEvent.getAbsolutePositionX() = clientX - getBodyAbsoluteBounds().x
3752
fun MouseEvent.getAbsolutePositionY() = clientY - getBodyAbsoluteBounds().y

editor-runtime/src/jsMain/kotlin/org/modelix/editor/JSCaretSelectionView.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,32 @@
1313
*/
1414
package org.modelix.editor
1515

16+
import kotlinx.html.TagConsumer
17+
import kotlinx.html.classes
18+
import kotlinx.html.div
1619
import org.w3c.dom.HTMLElement
1720

1821
class JSCaretSelectionView(selection: CaretSelection, val editor: JsEditorComponent) : SelectionView<CaretSelection>(selection) {
1922

20-
override fun updateBounds() {
23+
override fun <T> produceHtml(consumer: TagConsumer<T>) {
24+
consumer.div("caret own") {
25+
val textLength = selection.layoutable.cell.getVisibleText()?.length ?: 0
26+
if (textLength == 0) {
27+
// A typical case is a StringLiteral editor for an empty string.
28+
// There is no space around the empty text cell.
29+
// 'leftend' or 'rightend' styles would look like the caret is set into one of the '"' cells.
30+
} else if (selection.end == 0) {
31+
classes += "leftend"
32+
} else if (selection.end == textLength) {
33+
classes += "rightend"
34+
}
35+
}
36+
}
37+
38+
override fun update() {
2139
val layoutable = selection.layoutable
2240
val textElement = GeneratedHtmlMap.getOutput(selection.layoutable) ?: return
23-
val caretElement = GeneratedHtmlMap.getOutput(selection) ?: return
41+
val caretElement = GeneratedHtmlMap.getOutput(this) ?: return
2442
updateCaretBounds(textElement, selection.end, editor.getMainLayer(), caretElement)
2543
}
2644

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
package org.modelix.editor
15+
16+
import kotlinx.html.TagConsumer
17+
import kotlinx.html.div
18+
import kotlinx.html.span
19+
import kotlinx.html.style
20+
import org.w3c.dom.HTMLElement
21+
import org.w3c.dom.asList
22+
23+
class JSCellSelectionView(selection: CellSelection, val editor: JsEditorComponent) : SelectionView<CellSelection>(selection) {
24+
25+
override fun update() {
26+
val mainLayerBounds = editor.getMainLayer()?.getAbsoluteBounds() ?: ZERO_BOUNDS
27+
val selectionDom = GeneratedHtmlMap.getOutput(this) ?: return
28+
val lines: Map<TextLine, List<Layoutable>> = selection.getLayoutables().groupBy { it.getLine()!! }
29+
val lineSelectionDoms = selectionDom.childNodes.asList().filterIsInstance<HTMLElement>()
30+
31+
val applyBounds = ArrayList<() -> Unit>()
32+
33+
var selectionBounds: Bounds? = null
34+
for ((words, lineSelectionDom) in lines.values.zip(lineSelectionDoms)) {
35+
val wordSelectionDoms = lineSelectionDom.childNodes.asList().filterIsInstance<HTMLElement>()
36+
var lineBounds: Bounds? = null
37+
for ((word, wordSelectionDom) in words.zip(wordSelectionDoms)) {
38+
val wordDom = GeneratedHtmlMap.getOutput(word) ?: continue
39+
val wordBounds = wordDom.getAbsoluteBounds().relativeTo(mainLayerBounds)
40+
lineBounds = lineBounds.union(wordBounds)
41+
applyBounds += {
42+
with(wordSelectionDom.style) {
43+
position = "absolute"
44+
left = "${wordBounds.x - lineBounds!!.x}px"
45+
top = "${wordBounds.y - lineBounds.y}px"
46+
width = "${wordBounds.width}px"
47+
height = "${wordBounds.height}px"
48+
}
49+
}
50+
}
51+
selectionBounds = selectionBounds.union(lineBounds)
52+
applyBounds += {
53+
if (lineBounds != null) {
54+
with(lineSelectionDom.style) {
55+
position = "absolute"
56+
left = "${lineBounds.x - selectionBounds!!.x}px"
57+
top = "${lineBounds.y - selectionBounds.y}px"
58+
width = "${lineBounds.width}px"
59+
height = "${lineBounds.height}px"
60+
}
61+
}
62+
}
63+
}
64+
applyBounds += {
65+
if (selectionBounds != null) {
66+
with(selectionDom.style) {
67+
position = "absolute"
68+
left = "${selectionBounds.x}px"
69+
top = "${selectionBounds.y}px"
70+
width = "${selectionBounds.width}px"
71+
height = "${selectionBounds.height}px"
72+
}
73+
}
74+
}
75+
applyBounds.forEach { it() }
76+
}
77+
78+
override fun <T> produceHtml(consumer: TagConsumer<T>) {
79+
consumer.div("cell-selection own") {
80+
val lines: Map<TextLine, List<Layoutable>> = selection.getLayoutables().groupBy { it.getLine()!! }
81+
for (line in lines) {
82+
div("selected-line") {
83+
for (word in line.value) {
84+
span("selected-word") {
85+
style = "background-color:hsla(196, 67%, 45%, 0.3)"
86+
}
87+
}
88+
}
89+
}
90+
}
91+
}
92+
93+
companion object {
94+
fun updateCaretBounds(textElement: HTMLElement, caretPos: Int, coordinatesElement: HTMLElement?, caretElement: HTMLElement) {
95+
val text = textElement.innerText
96+
val textLength = text.length
97+
val cellAbsoluteBounds = textElement.getAbsoluteInnerBounds()
98+
val cellRelativeBounds = cellAbsoluteBounds.relativeTo(coordinatesElement?.getAbsoluteBounds() ?: ZERO_BOUNDS)
99+
val characterWidth = if (textLength == 0) 0.0 else cellAbsoluteBounds.width / textLength
100+
val caretX = cellRelativeBounds.x + caretPos * characterWidth
101+
val leftEnd = caretPos == 0
102+
val rightEnd = caretPos == textLength
103+
val caretOffsetX = if (rightEnd && !leftEnd) -4 else -1
104+
val caretOffsetY = if (leftEnd || rightEnd) -1 else 0
105+
caretElement.style.height = "${cellRelativeBounds.height}px"
106+
caretElement.style.left = "${caretX + caretOffsetX}px"
107+
caretElement.style.top = "${cellRelativeBounds.y + caretOffsetY}px"
108+
}
109+
}
110+
}

editor-runtime/src/jsMain/kotlin/org/modelix/editor/JsEditorComponent.kt

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package org.modelix.editor
22

33
import kotlinx.browser.document
4+
import kotlinx.html.TagConsumer
5+
import kotlinx.html.div
46
import kotlinx.html.dom.create
57
import kotlinx.html.js.div
68
import kotlinx.html.tabIndex
@@ -12,7 +14,7 @@ import kotlin.math.abs
1214
import kotlin.math.min
1315
import kotlin.math.roundToInt
1416

15-
class JsEditorComponent(engine: EditorEngine, rootCellCreator: (EditorState) -> Cell) : EditorComponent(engine, rootCellCreator) {
17+
class JsEditorComponent(engine: EditorEngine, rootCellCreator: (EditorState) -> Cell) : EditorComponent(engine, rootCellCreator), IProducesHtml {
1618

1719
private var containerElement: HTMLElement = document.create.div("js-editor-component") {
1820
tabIndex = "-1" // allows setting keyboard focus
@@ -40,19 +42,36 @@ class JsEditorComponent(engine: EditorEngine, rootCellCreator: (EditorState) ->
4042
super.update()
4143
updateSelectionView()
4244
updateHtml()
43-
selectionView?.updateBounds()
45+
selectionView?.update()
4446
codeCompletionMenu?.let { JSCodeCompletionMenuUI(it, this).updateBounds() }
4547
}
4648

4749
private fun updateSelectionView() {
4850
if (selectionView?.selection != getSelection()) {
4951
selectionView = when (val selection = getSelection()) {
5052
is CaretSelection -> JSCaretSelectionView(selection, this)
53+
is CellSelection -> JSCellSelectionView(selection, this)
5154
else -> null
5255
}
5356
}
5457
}
5558

59+
override fun <T> produceHtml(consumer: TagConsumer<T>) {
60+
consumer.div("editor") {
61+
div(MAIN_LAYER_CLASS_NAME) {
62+
produceChild(getRootCell().layout)
63+
}
64+
div("selection-layer relative-layer") {
65+
produceChild(selectionView)
66+
}
67+
div("popup-layer relative-layer") {
68+
produceChild(codeCompletionMenu)
69+
}
70+
}
71+
}
72+
73+
override fun isHtmlOutputValid(): Boolean = false
74+
5675
fun updateHtml() {
5776
val oldEditorElement = GeneratedHtmlMap.getOutput(this)
5877
val newEditorElement = IncrementalJSDOMBuilder(document, oldEditorElement).produce(this)()

0 commit comments

Comments
 (0)