Skip to content

Commit

Permalink
For #6518: templates cursor navigation
Browse files Browse the repository at this point in the history
  • Loading branch information
Erik Bruchez committed Nov 11, 2024
1 parent 7608323 commit 34643f6
Show file tree
Hide file tree
Showing 6 changed files with 158 additions and 61 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ object FormBuilderApp extends App {
ControlEditor
ControlLabelHintTextEditor
GridWallDnD
FormSettings

BrowserCheck.checkSupportedBrowser()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@
*/
package org.orbeon.builder

import io.udash.wrappers.jquery.JQueryCallbacks
import org.orbeon.web.DomSupport.*
import org.orbeon.xforms.{$, AjaxClient, AjaxEvent, Support}
import org.scalajs.dom
import io.udash.wrappers.jquery.{JQueryCallback, JQueryCallbacks}

import scala.scalajs.js


object FormBuilderPrivateAPI extends js.Object {

def updateLocationDocumentId(documentId: String): Unit = {
Expand Down Expand Up @@ -60,30 +62,10 @@ object FormBuilderPrivateAPI extends js.Object {
// Right now this doesn't handle scrolling horizontally.
def moveFocusedCellIntoView(): Unit =
for {
selectedElem <- Option(dom.document.querySelector(".fb-main .fb-selected"))
mainElem <- dom.document.getElementsByClassName("fb-main")(0): js.UndefOr[dom.Element]
mainRect = mainElem.getBoundingClientRect()
cellRect = selectedElem.getBoundingClientRect()
isEntirelyContained =
cellRect.left >= mainRect.left &&
cellRect.top >= mainRect.top &&
cellRect.bottom <= mainRect.bottom &&
cellRect.right <= mainRect.right
if ! isEntirelyContained
mainInnerElem <- dom.document.getElementsByClassName("fb-main-inner")(0): js.UndefOr[dom.Element]
mainInnerRect = mainInnerElem.getBoundingClientRect()
selectedElem <- dom.document.querySelectorOpt(".fb-main .fb-selected")
mainInnerElem <- dom.document.querySelectorOpt(".fb-main-inner")
mainElem <- dom.document.querySelectorOpt(".fb-main")
} locally {

val isBelow = cellRect.bottom > mainRect.bottom

val scrollTop =
if (isBelow)
mainRect.top - mainInnerRect.top + cellRect.bottom - mainRect.bottom + 50
else
mainRect.top - mainInnerRect.top - (mainRect.top - cellRect.top + 50)

mainElem.asInstanceOf[js.Dynamic].scrollTo(
js.Dynamic.literal(top = scrollTop, behavior = "smooth")
)
moveIntoViewIfNeeded(mainElem, mainInnerElem, selectedElem)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package org.orbeon.builder

import org.orbeon.datatypes.Direction
import org.orbeon.web.DomSupport.{DomElemOps, moveIntoViewIfNeeded}
import org.orbeon.xforms.EventListenerSupport
import org.orbeon.xforms.facade.{XBL, XBLCompanion}
import org.scalajs.dom.{KeyboardEvent, html}

import scala.scalajs.js


object FormSettings {

XBL.declareCompanion("fb|dialog-form-settings", js.constructorOf[ControlSettingsCompanion])

private class ControlSettingsCompanion(containerElem: html.Element) extends XBLCompanion {

private object EventSupport extends EventListenerSupport

private val KeyMapping = Map(
"ArrowLeft" -> Direction.Left,
"ArrowRight" -> Direction.Right,
"ArrowUp" -> Direction.Up,
"ArrowDown" -> Direction.Down
)

def dialogOpening(): Unit = {

val isNew = containerElem.querySelectorOpt(".fb-settings-mode-new").nonEmpty

def findCardsForDirections: Map[Direction, html.Element] = {
val cards = containerElem.querySelectorAllT(".fb-template-card")

Some(cards.indexWhere(_.classList.contains("xforms-repeat-selected-item-1")))
.filter(_ >= 0)
.map { index =>

val lifted = cards.lift

(
lifted(index - 1).map((Direction.Left : Direction) -> _).toList :::
lifted(index + 1).map((Direction.Right: Direction) -> _).toList :::
lifted(index - 4).map((Direction.Up : Direction) -> _).toList :::
lifted(index + 4).map((Direction.Down : Direction) -> _).toList
).toMap
}
.getOrElse(Map.empty)
}

if (isNew)
EventSupport.addListener[KeyboardEvent](containerElem, "keydown", e =>
KeyMapping.get(e.key).foreach { direction =>
findCardsForDirections.get(direction).foreach { card =>
moveIntoViewIfNeeded(
containerElem.querySelectorOpt(".fb-template-cards-container").get,
containerElem.querySelectorOpt(".fb-template-cards").get,
card
)
card.click()
}
}
)
}

def dialogClosing(): Unit =
EventSupport.clearAllListeners()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -729,14 +729,18 @@ body.orbeon {

.fb-dialog-form-settings-fields {

div.fb-template-cards-container {
overflow-y: scroll;
padding: 5px; // so that selection outline/shadow is not clipped
}

div.fb-template-cards { // try to make stronger
display: flex;
flex-wrap: wrap;
gap: 1em;
min-height: 234px; // empirically fit one line
max-height: 482px; // empirically fit two lines
overflow-y: scroll;
padding: 5px; // so that selection outline/shadow is not clipped

justify-content: space-around;

& > span {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:oxf="http://www.orbeon.com/oxf/processors">

<xbl:binding id="fb-dialog-form-settings" element="fb|dialog-form-settings">
<xbl:binding
id="fb-dialog-form-settings"
element="fb|dialog-form-settings"
xxbl:mode="javascript-lifecycle">
<xbl:handlers>
<!-- Handler to open dialog -->
<xbl:handler event="fb-show-dialog xxforms-dialog-open" phase="target">
Expand Down Expand Up @@ -316,6 +319,10 @@
control="fb-general-settings-grid"/>
</xf:action>

<xf:action type="javascript" event="xxforms-dialog-open" target="#observer">
ORBEON.xforms.XBL.instanceForControl(this).dialogOpening();
</xf:action>

</xbl:handler>

<!-- Save and close if valid -->
Expand Down Expand Up @@ -466,8 +473,14 @@

<!-- Hide this dialog -->
<xxf:hide dialog="dialog"/>

</xf:action>
</xbl:handler>
<xbl:handler observer="dialog" event="xxforms-dialog-close" phase="target">
<xf:action type="javascript" event="xxforms-dialog-close" target="#observer">
ORBEON.xforms.XBL.instanceForControl(this).dialogClosing();
</xf:action>
</xbl:handler>.
</xbl:handlers>
<xbl:implementation>
<xf:model id="model">
Expand Down Expand Up @@ -881,39 +894,41 @@

<fr:grid id="fb-templates-grid">
<fr:c x="1" y="1" w="12" h="1">
<xh:div class="fb-template-cards">
<xf:repeat ref="instance('template-forms')/*" id="form-template-repeat">
<xh:div class="fb-template-card" title="{{title}}">
<xf:output class="fb-template-title" value="title"/>
<xf:output class="fb-template-thumbnail" mediatype="image/*" ref="thumbnail[xxf:non-blank()]"/>
</xh:div>
</xf:repeat>
<!-- If the user selects a template, and there is an app name associated,
then set the app name to the selected template's app name. This makes
sense because the user is more likely to be creating a form in an app
that matches the template's app. The user can change this later.

The user can have access to any app, or to a specific set. The search
only searches for and returns templates from apps the user has access
to. So it is safe to use those app names here.

Some templates are in no app (like the "blank" template), which means
they are global. If the user changes between a template with an app,
and then a template without an app, we don't want the new app to be
sticky. So we restore it to the original app. This makes sense because
the user cannot directly change the app name in `new` mode.
-->
<xf:setvalue
event="xxforms-index-changed"
observer="form-template-repeat"
ref="$i/app"
value="
(
(: Selected app if present :)
instance('template-forms')/*[event('xxf:new-index')]/app[xxf:non-blank()]/string(),
(: Original app passed when the dialog opened otherwise :)
$i/original-app
)[1]"/>
<xh:div class="fb-template-cards-container">
<xh:div class="fb-template-cards">
<xf:repeat ref="instance('template-forms')/*" id="form-template-repeat">
<xh:div class="fb-template-card" title="{{title}}">
<xf:output class="fb-template-title" value="title"/>
<xf:output class="fb-template-thumbnail" mediatype="image/*" ref="thumbnail[xxf:non-blank()]"/>
</xh:div>
</xf:repeat>
<!-- If the user selects a template, and there is an app name associated,
then set the app name to the selected template's app name. This makes
sense because the user is more likely to be creating a form in an app
that matches the template's app. The user can change this later.

The user can have access to any app, or to a specific set. The search
only searches for and returns templates from apps the user has access
to. So it is safe to use those app names here.

Some templates are in no app (like the "blank" template), which means
they are global. If the user changes between a template with an app,
and then a template without an app, we don't want the new app to be
sticky. So we restore it to the original app. This makes sense because
the user cannot directly change the app name in `new` mode.
-->
<xf:setvalue
event="xxforms-index-changed"
observer="form-template-repeat"
ref="$i/app"
value="
(
(: Selected app if present :)
instance('template-forms')/*[event('xxf:new-index')]/app[xxf:non-blank()]/string(),
(: Original app passed when the dialog opened otherwise :)
$i/original-app
)[1]"/>
</xh:div>
</xh:div>
</fr:c>
<fr:c x="1" y="2" w="12" h="1">
Expand Down
27 changes: 27 additions & 0 deletions web-support/src/main/scala/org/orbeon/web/DomSupport.scala
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,31 @@ object DomSupport {
}
element.id
}

def moveIntoViewIfNeeded(containerElem: html.Element, innerContainer: html.Element, itemElem: html.Element): Unit = {
val containerRect = containerElem.getBoundingClientRect()
val itemRect = itemElem.getBoundingClientRect()
val isEntirelyContained =
itemRect.left >= containerRect.left &&
itemRect.top >= containerRect.top &&
itemRect.bottom <= containerRect.bottom &&
itemRect.right <= containerRect.right
if (! isEntirelyContained) {

val overflowsBelow = itemRect.bottom > containerRect.bottom

val Margin = 50

val mainInnerRect = innerContainer.getBoundingClientRect()
val scrollTop =
if (overflowsBelow)
containerRect.top - mainInnerRect.top + itemRect.bottom - containerRect.bottom + Margin
else
containerRect.top - mainInnerRect.top - (containerRect.top - itemRect.top + Margin)

containerElem.asInstanceOf[js.Dynamic].scrollTo(
js.Dynamic.literal(top = scrollTop, behavior = "smooth")
)
}
}
}

0 comments on commit 34643f6

Please sign in to comment.