diff --git a/demo-v14/src/main/java/org/vaadin/miki/MainView.java b/demo-v14/src/main/java/org/vaadin/miki/MainView.java index da291534..8dd9e306 100644 --- a/demo-v14/src/main/java/org/vaadin/miki/MainView.java +++ b/demo-v14/src/main/java/org/vaadin/miki/MainView.java @@ -39,6 +39,10 @@ import org.vaadin.miki.superfields.tabs.SuperTabs; import org.vaadin.miki.superfields.tabs.TabHandler; import org.vaadin.miki.superfields.tabs.TabHandlers; +import org.vaadin.miki.superfields.text.CanSelectText; +import org.vaadin.miki.superfields.text.SuperTextArea; +import org.vaadin.miki.superfields.text.SuperTextField; +import org.vaadin.miki.superfields.text.TextSelectionNotifier; import org.vaadin.miki.superfields.unload.UnloadObserver; import java.time.LocalDate; @@ -132,6 +136,23 @@ private void buildHasValue(Component component, Consumer callback) ((HasValue) component).addValueChangeListener(this::onAnyValueChanged); } + private void buildCanSelectText(Component component, Consumer callback) { + final Button selectAll = new Button("Select all", event -> ((CanSelectText)component).selectAll()); + final Button selectNone = new Button("Select none", event -> ((CanSelectText)component).selectNone()); + final HorizontalLayout layout = new HorizontalLayout(new Span("Type something in the field, then click one of the buttons:"), selectAll, selectNone); + layout.setAlignItems(Alignment.CENTER); + callback.accept(new Component[]{ + layout + }); + if(component instanceof TextSelectionNotifier) { + final Span selection = new Span(); + ((TextSelectionNotifier) component).addTextSelectionListener(event -> selection.setText(event.getSelectedText())); + callback.accept(new Component[]{ + new HorizontalLayout(new Span("You can also select text yourself with keyboard our mouse. Here is the current selection: <"), selection, new Span(">")) + }); + } + } + @SuppressWarnings("unchecked") private void buildItemGrid(Component component, Consumer callback) { final RadioButtonGroup buttons = new RadioButtonGroup<>(); @@ -258,6 +279,8 @@ public MainView() { this.components.put(SuperBigDecimalField.class, new SuperBigDecimalField("Big decimal (12 + 3 digits):").withMaximumIntegerDigits(12).withMaximumFractionDigits(3).withMinimumFractionDigits(1).withId("big-decimal")); this.components.put(SuperDatePicker.class, new SuperDatePicker("Pick a date:").withDatePattern(DatePatterns.YYYY_MM_DD).withValue(LocalDate.now())); this.components.put(SuperDateTimePicker.class, new SuperDateTimePicker("Pick a date and time:").withDatePattern(DatePatterns.M_D_YYYY_SLASH).withValue(LocalDateTime.now())); + this.components.put(SuperTextField.class, new SuperTextField("Type something:").withPlaceholder("(nothing typed)").withId("super-text-field").withReceivingSelectionEventsFromClient(true)); + this.components.put(SuperTextArea.class, new SuperTextArea("Type a lot of something:").withPlaceholder("(nothing typed)").withId("super-text-area").withReceivingSelectionEventsFromClient(true)); this.components.put(SuperTabs.class, new SuperTabs((Supplier) HorizontalLayout::new) .withTabContentGenerator(s -> new Paragraph("Did you know? All SuperFields are "+s)) .withItems("Java friendly", "Super-configurable", "Open source") @@ -298,6 +321,7 @@ public MainView() { this.contentBuilders.put(AbstractSuperNumberField.class, this::buildAbstractSuperNumberField); this.contentBuilders.put(HasLocale.class, this::buildHasLocale); this.contentBuilders.put(HasValue.class, this::buildHasValue); + this.contentBuilders.put(CanSelectText.class, this::buildCanSelectText); this.contentBuilders.put(ItemGrid.class, this::buildItemGrid); this.contentBuilders.put(HasDatePattern.class, this::buildHasDatePattern); this.contentBuilders.put(SuperTabs.class, this::buildSuperTabs); diff --git a/superfields/src/main/java/org/vaadin/miki/superfields/dates/SuperDatePicker.java b/superfields/src/main/java/org/vaadin/miki/superfields/dates/SuperDatePicker.java index f8a8f264..5438901a 100644 --- a/superfields/src/main/java/org/vaadin/miki/superfields/dates/SuperDatePicker.java +++ b/superfields/src/main/java/org/vaadin/miki/superfields/dates/SuperDatePicker.java @@ -23,6 +23,7 @@ */ @JsModule("./super-date-picker.js") @Tag("super-date-picker") +@SuppressWarnings("squid:S110") // there is no way to reduce the number of parent classes public class SuperDatePicker extends DatePicker implements HasLocale, HasLabel, HasPlaceholder, HasDatePattern, WithLocaleMixin, WithLabelMixin, diff --git a/superfields/src/main/java/org/vaadin/miki/superfields/dates/SuperDateTimePicker.java b/superfields/src/main/java/org/vaadin/miki/superfields/dates/SuperDateTimePicker.java index dce504f9..b71f3764 100644 --- a/superfields/src/main/java/org/vaadin/miki/superfields/dates/SuperDateTimePicker.java +++ b/superfields/src/main/java/org/vaadin/miki/superfields/dates/SuperDateTimePicker.java @@ -25,6 +25,7 @@ */ @JsModule("./super-date-time-picker.js") @Tag("super-date-time-picker") +@SuppressWarnings("squid:S110") // there is no way to reduce the number of parent classes public class SuperDateTimePicker extends DateTimePicker implements HasLocale, HasLabel, HasDatePattern, WithLocaleMixin, WithLabelMixin, diff --git a/superfields/src/main/java/org/vaadin/miki/superfields/text/CanReceiveSelectionEventsFromClient.java b/superfields/src/main/java/org/vaadin/miki/superfields/text/CanReceiveSelectionEventsFromClient.java new file mode 100644 index 00000000..489ae517 --- /dev/null +++ b/superfields/src/main/java/org/vaadin/miki/superfields/text/CanReceiveSelectionEventsFromClient.java @@ -0,0 +1,17 @@ +package org.vaadin.miki.superfields.text; + +public interface CanReceiveSelectionEventsFromClient { + /** + * Check if client will inform server on selection change. + * Note: this feature is by default turned off. + * @return When {@code true}, each selection change in the client-side component will result in this component broadcasting a {@link TextSelectionEvent}. + */ + boolean isReceivingSelectionEventsFromClient(); + + /** + * Configures sending events by the client-side component. + * Note: this feature is by default turned off. + * @param receivingSelectionEventsFromClient When {@code false}, selecting text in client-side component will not send an event to server-side component. When {@code true}, it will. + */ + void setReceivingSelectionEventsFromClient(boolean receivingSelectionEventsFromClient); +} diff --git a/superfields/src/main/java/org/vaadin/miki/superfields/text/CanSelectText.java b/superfields/src/main/java/org/vaadin/miki/superfields/text/CanSelectText.java new file mode 100644 index 00000000..11604944 --- /dev/null +++ b/superfields/src/main/java/org/vaadin/miki/superfields/text/CanSelectText.java @@ -0,0 +1,19 @@ +package org.vaadin.miki.superfields.text; + +import com.vaadin.flow.component.HasElement; + +/** + * Marker interface for components that can select text. + * Handles selection using client-side JavaScript. + * @author miki + * @since 2020-05-29 + */ +public interface CanSelectText extends HasElement { + + void selectAll(); + + void selectNone(); + + void select(int from, int to); + +} diff --git a/superfields/src/main/java/org/vaadin/miki/superfields/text/SuperTextArea.java b/superfields/src/main/java/org/vaadin/miki/superfields/text/SuperTextArea.java new file mode 100644 index 00000000..026d2980 --- /dev/null +++ b/superfields/src/main/java/org/vaadin/miki/superfields/text/SuperTextArea.java @@ -0,0 +1,103 @@ +package org.vaadin.miki.superfields.text; + +import com.vaadin.flow.component.AbstractField; +import com.vaadin.flow.component.ClientCallable; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.dependency.JsModule; +import com.vaadin.flow.component.textfield.TextArea; +import com.vaadin.flow.shared.Registration; +import org.vaadin.miki.markers.HasLabel; +import org.vaadin.miki.markers.HasPlaceholder; +import org.vaadin.miki.markers.WithIdMixin; +import org.vaadin.miki.markers.WithLabelMixin; +import org.vaadin.miki.markers.WithPlaceholderMixin; +import org.vaadin.miki.markers.WithValueMixin; + +/** + * An extension of {@link TextArea} with some useful features. + * @author miki + * @since 2020-06-01 + */ +@Tag("super-text-area") +@JsModule("./super-text-area.js") +@SuppressWarnings("squid:S110") // there is no way to reduce the number of parent classes +public class SuperTextArea extends TextArea implements CanSelectText, TextSelectionNotifier, + HasLabel, HasPlaceholder, WithIdMixin, WithLabelMixin, WithPlaceholderMixin, + WithReceivingSelectionEventsFromClientMixin, + WithValueMixin, String, SuperTextArea> { + + private final TextSelectionDelegate delegate = new TextSelectionDelegate<>(this); + + private boolean receivingSelectionEventsFromClient = false; + + public SuperTextArea() { + } + + public SuperTextArea(String label) { + super(label); + } + + public SuperTextArea(String label, String placeholder) { + super(label, placeholder); + } + + public SuperTextArea(String label, String initialValue, String placeholder) { + super(label, initialValue, placeholder); + } + + public SuperTextArea(ValueChangeListener> listener) { + super(listener); + } + + public SuperTextArea(String label, ValueChangeListener> listener) { + super(label, listener); + } + + public SuperTextArea(String label, String initialValue, ValueChangeListener> listener) { + super(label, initialValue, listener); + } + + @Override + public boolean isReceivingSelectionEventsFromClient() { + return this.receivingSelectionEventsFromClient; + } + + @Override + public void setReceivingSelectionEventsFromClient(boolean receivingSelectionEventsFromClient) { + this.receivingSelectionEventsFromClient = receivingSelectionEventsFromClient; + this.delegate.informClientAboutSendingEvents(receivingSelectionEventsFromClient); + } + + @Override + public void selectAll() { + this.delegate.selectAll(this::getValue, this::getEventBus); + } + + @Override + public void selectNone() { + this.delegate.selectNone(this::getEventBus); + } + + @Override + public void select(int from, int to) { + this.delegate.select(this::getValue, this::getEventBus, from, to); + } + + @Override + @SuppressWarnings("unchecked") + public Registration addTextSelectionListener(TextSelectionListener listener) { + return this.getEventBus().addListener((Class>)(Class)TextSelectionEvent.class, listener); + } + + @ClientCallable + private void selectionChanged(int start, int end, String selection) { + TextSelectionEvent event = new TextSelectionEvent<>(this, true, start, end, selection); + this.delegate.fireTextSelectionEvent(this.getEventBus(), event); + } + + @Override + public void setValue(String value) { + this.delegate.updateAttributeOnValueChange(this::getEventBus); + super.setValue(value); + } +} diff --git a/superfields/src/main/java/org/vaadin/miki/superfields/text/SuperTextField.java b/superfields/src/main/java/org/vaadin/miki/superfields/text/SuperTextField.java new file mode 100644 index 00000000..b9854ce1 --- /dev/null +++ b/superfields/src/main/java/org/vaadin/miki/superfields/text/SuperTextField.java @@ -0,0 +1,121 @@ +package org.vaadin.miki.superfields.text; + +import com.vaadin.flow.component.AbstractField; +import com.vaadin.flow.component.AttachEvent; +import com.vaadin.flow.component.ClientCallable; +import com.vaadin.flow.component.DetachEvent; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.dependency.JsModule; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.shared.Registration; +import org.vaadin.miki.markers.HasLabel; +import org.vaadin.miki.markers.HasPlaceholder; +import org.vaadin.miki.markers.WithIdMixin; +import org.vaadin.miki.markers.WithLabelMixin; +import org.vaadin.miki.markers.WithPlaceholderMixin; +import org.vaadin.miki.markers.WithValueMixin; + +/** + * An extension of {@link TextField} with some useful (hopefully) features. + * @author miki + * @since 2020-05-29 + */ +@Tag("super-text-field") +@JsModule("./super-text-field.js") +@SuppressWarnings("squid:S110") // there is no way to reduce the number of parent classes +public class SuperTextField extends TextField implements CanSelectText, TextSelectionNotifier, + HasLabel, HasPlaceholder, + WithIdMixin, WithLabelMixin, WithPlaceholderMixin, + WithValueMixin, String, SuperTextField>, + WithReceivingSelectionEventsFromClientMixin { + + private boolean receivingSelectionEventsFromClient = false; + + private final TextSelectionDelegate delegate = new TextSelectionDelegate<>(this); + + public SuperTextField() { + super(); + } + + public SuperTextField(String label) { + super(label); + } + + public SuperTextField(String label, String placeholder) { + super(label, placeholder); + } + + public SuperTextField(String label, String initialValue, String placeholder) { + super(label, initialValue, placeholder); + } + + public SuperTextField(ValueChangeListener> listener) { + super(listener); + } + + public SuperTextField(String label, ValueChangeListener> listener) { + super(label, listener); + } + + public SuperTextField(String label, String initialValue, ValueChangeListener> listener) { + super(label, initialValue, listener); + } + + @Override + protected void onAttach(AttachEvent attachEvent) { + this.delegate.informClientAboutSendingEvents(this.isReceivingSelectionEventsFromClient()); + super.onAttach(attachEvent); + } + + @Override + protected void onDetach(DetachEvent detachEvent) { + // detaching means server should not be informed + if(this.isReceivingSelectionEventsFromClient()) + this.delegate.informClientAboutSendingEvents(false); + super.onDetach(detachEvent); + } + + @Override + @SuppressWarnings("unchecked") + public Registration addTextSelectionListener(TextSelectionListener listener) { + return this.getEventBus().addListener((Class>)(Class)TextSelectionEvent.class, listener); + } + + @Override + public void selectAll() { + this.delegate.selectAll(this::getValue, this::getEventBus); + } + + @Override + public void selectNone() { + this.delegate.selectNone(this::getEventBus); + } + + @Override + public void select(int from, int to) { + this.delegate.select(this::getValue, this::getEventBus, from, to); + } + + @ClientCallable + private void selectionChanged(int start, int end, String selection) { + TextSelectionEvent event = new TextSelectionEvent<>(this, true, start, end, selection); + this.delegate.fireTextSelectionEvent(this.getEventBus(), event); + } + + @Override + public boolean isReceivingSelectionEventsFromClient() { + return this.receivingSelectionEventsFromClient; + } + + @Override + public void setReceivingSelectionEventsFromClient(boolean receivingSelectionEventsFromClient) { + this.receivingSelectionEventsFromClient = receivingSelectionEventsFromClient; + this.delegate.informClientAboutSendingEvents(receivingSelectionEventsFromClient); + } + + @Override + public void setValue(String value) { + this.delegate.updateAttributeOnValueChange(this::getEventBus); + super.setValue(value); + } +} diff --git a/superfields/src/main/java/org/vaadin/miki/superfields/text/TextSelectionDelegate.java b/superfields/src/main/java/org/vaadin/miki/superfields/text/TextSelectionDelegate.java new file mode 100644 index 00000000..01346f61 --- /dev/null +++ b/superfields/src/main/java/org/vaadin/miki/superfields/text/TextSelectionDelegate.java @@ -0,0 +1,128 @@ +package org.vaadin.miki.superfields.text; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.ComponentEventBus; + +import java.io.Serializable; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; + +/** + * Internal class that handles common behaviour related to text selection. + * Note: this is for internal use only. + * @author miki + * @since 2020-06-01 + */ +class TextSelectionDelegate implements Serializable { + + /** + * Defines the name of the HTML attribute that contains the selected text. + */ + public static final String SELECTED_TEXT_ATTRIBUTE_NAME = "data-selected-text"; + + private final C source; + + /** + * Creates the delegate for a given component. + * @param source Source of all events, data, etc. + */ + TextSelectionDelegate(C source) { + this.source = source; + } + + /** + * Sends information to the client side about whether or not it should forward text selection change events. + * @param value When {@code true}, client-side will notify server about changes in text selection. + */ + protected void informClientAboutSendingEvents(boolean value) { + this.source.getElement().getNode().runWhenAttached(ui -> ui.beforeClientResponse(this.source, context -> + this.source.getElement().callJsFunction( + "setCallingServer", + value + ) + )); + } + + /** + * Fires text selection event. + * @param eventBus Event bus. + * @param event Event with information about text selection. + */ + protected void fireTextSelectionEvent(ComponentEventBus eventBus, TextSelectionEvent event) { + eventBus.fireEvent(event); + } + + private void selectionChanged(ComponentEventBus eventBus, int start, int end, String selection) { + TextSelectionEvent event = new TextSelectionEvent<>(this.source, true, start, end, selection); + this.fireTextSelectionEvent(eventBus, event); + } + + /** + * Selects all text. + * @param valueSupplier Way of getting current value. Needed if no client notifications. + * @param eventBusSupplier Way of getting event bus. Needed if no client notifications. + */ + void selectAll(Supplier valueSupplier, Supplier eventBusSupplier) { + this.source.getElement().getNode().runWhenAttached(ui -> ui.beforeClientResponse(this.source, context -> + this.source.getElement().callJsFunction("selectAll", this.source.getElement()) + )); + // send event if the client is not doing it + if(!this.source.isReceivingSelectionEventsFromClient()) { + final String value = valueSupplier.get(); + this.source.getElement().setAttribute(SELECTED_TEXT_ATTRIBUTE_NAME, value); + this.selectionChanged(eventBusSupplier.get(), 0, value.length(), value); + } + } + + /** + * Selects no text. + * @param eventBusSupplier Way of getting event bus. Needed if no client notifications. + */ + void selectNone(Supplier eventBusSupplier) { + this.source.getElement().getNode().runWhenAttached(ui -> ui.beforeClientResponse(this.source, context -> + this.source.getElement().callJsFunction("selectNone", this.source.getElement()) + )); + // send event if the client is not doing it + if(!this.source.isReceivingSelectionEventsFromClient()) { + this.source.getElement().setAttribute(SELECTED_TEXT_ATTRIBUTE_NAME, ""); + this.selectionChanged(eventBusSupplier.get(), -1, -1, ""); + } + } + + /** + * Selects some text. + * @param valueSupplier Way of getting current value. Needed if no client notifications. + * @param eventBusSupplier Way of getting event bus. Needed if no client notifications. + * @param from Selection starting index, inclusive. + * @param to Selection end index, exclusive. + */ + void select(Supplier valueSupplier, Supplier eventBusSupplier, int from, int to) { + if(from <= to) + this.source.getElement().getNode().runWhenAttached(ui -> ui.beforeClientResponse(this.source, context -> + this.source.getElement().callJsFunction("select", this.source.getElement(), from, to) + )); + // send event if the client is not doing it + if(!this.source.isReceivingSelectionEventsFromClient()) { + final String value = valueSupplier.get().substring(from, to); + this.source.getElement().setAttribute(SELECTED_TEXT_ATTRIBUTE_NAME, value); + this.selectionChanged(eventBusSupplier.get(), from, to, value); + } + } + + /** + * Handles selection change on value change if there are no client notifications. + * Does nothing if the component is receiving client-side notifications. + * @param eventBusSupplier Way of getting event bus. + */ + void updateAttributeOnValueChange(Supplier eventBusSupplier) { + // special case here: if there was selection, no client-side events are caught and value is set, event must be fired + if(!this.source.isReceivingSelectionEventsFromClient()) { + final String lastSelected = Optional.ofNullable(this.source.getElement().getAttribute(SELECTED_TEXT_ATTRIBUTE_NAME)).orElse(""); + this.source.getElement().setAttribute(SELECTED_TEXT_ATTRIBUTE_NAME, ""); + if(!Objects.equals(lastSelected, "")) + this.fireTextSelectionEvent(eventBusSupplier.get(), new TextSelectionEvent<>(this.source, false, -1, -1, "")); + } + } + +} diff --git a/superfields/src/main/java/org/vaadin/miki/superfields/text/TextSelectionEvent.java b/superfields/src/main/java/org/vaadin/miki/superfields/text/TextSelectionEvent.java new file mode 100644 index 00000000..13155984 --- /dev/null +++ b/superfields/src/main/java/org/vaadin/miki/superfields/text/TextSelectionEvent.java @@ -0,0 +1,53 @@ +package org.vaadin.miki.superfields.text; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.ComponentEvent; + +import java.util.Optional; + +/** + * Component event with information about selected text. + * @param Type of component that broadcast the event. + * @author miki + * @since 2020-05-30 + */ +public final class TextSelectionEvent extends ComponentEvent { + + public static final int NO_SELECTION = -1; + + private final int selectionStart; + private final int selectionEnd; + private final String selectedText; + + /** + * Creates a new event using the given source and indicator whether the + * event originated from the client side or the server side. + * @param source the source component + * @param fromClient true if the event originated from the client + * @param selectionStart Where selection starts in the component. Can be {@link #NO_SELECTION}. + * @param selectionEnd Where selection ends in the component. Can be {@link #NO_SELECTION}. + * @param selectedText What is the selected text. Can be empty. + */ + public TextSelectionEvent(T source, boolean fromClient, int selectionStart, int selectionEnd, String selectedText) { + super(source, fromClient); + this.selectionStart = selectionStart; + this.selectionEnd = selectionEnd; + this.selectedText = Optional.ofNullable(selectedText).orElse(""); + } + + public int getSelectionStart() { + return selectionStart; + } + + public int getSelectionEnd() { + return selectionEnd; + } + + public String getSelectedText() { + return selectedText; + } + + public boolean isAnythingSelected() { + return this.selectionStart != NO_SELECTION && this.selectionEnd != this.selectionStart && !this.selectedText.isEmpty(); + } +} diff --git a/superfields/src/main/java/org/vaadin/miki/superfields/text/TextSelectionListener.java b/superfields/src/main/java/org/vaadin/miki/superfields/text/TextSelectionListener.java new file mode 100644 index 00000000..1f007f6d --- /dev/null +++ b/superfields/src/main/java/org/vaadin/miki/superfields/text/TextSelectionListener.java @@ -0,0 +1,13 @@ +package org.vaadin.miki.superfields.text; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.ComponentEventListener; + +/** + * Marker interface for objects + * @param Component type. + * @author miki + * @since 2020-05-30 + */ +public interface TextSelectionListener extends ComponentEventListener> { +} diff --git a/superfields/src/main/java/org/vaadin/miki/superfields/text/TextSelectionNotifier.java b/superfields/src/main/java/org/vaadin/miki/superfields/text/TextSelectionNotifier.java new file mode 100644 index 00000000..97d3e3f7 --- /dev/null +++ b/superfields/src/main/java/org/vaadin/miki/superfields/text/TextSelectionNotifier.java @@ -0,0 +1,21 @@ +package org.vaadin.miki.superfields.text; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.shared.Registration; + +/** + * Marker interface for objects that broadcast {@link TextSelectionEvent}. + * @author miki + * @since 2020-05-30 + */ +@FunctionalInterface +public interface TextSelectionNotifier { + + /** + * Adds the listener. + * @param listener A listener to add. + * @return A {@link Registration} that can be used to stop listening to the event. + */ + Registration addTextSelectionListener(TextSelectionListener listener); + +} diff --git a/superfields/src/main/java/org/vaadin/miki/superfields/text/WithReceivingSelectionEventsFromClientMixin.java b/superfields/src/main/java/org/vaadin/miki/superfields/text/WithReceivingSelectionEventsFromClientMixin.java new file mode 100644 index 00000000..cb9fbde0 --- /dev/null +++ b/superfields/src/main/java/org/vaadin/miki/superfields/text/WithReceivingSelectionEventsFromClientMixin.java @@ -0,0 +1,25 @@ +package org.vaadin.miki.superfields.text; + +/** + * Marker interface for chaining {@link #setReceivingSelectionEventsFromClient(boolean)}. + * @param Self type. + * @author miki + * @since 2020-06-01 + */ +public interface WithReceivingSelectionEventsFromClientMixin extends CanReceiveSelectionEventsFromClient { + + /** + * Chains {@link #setReceivingSelectionEventsFromClient(boolean)} and returns itself. + * Note: this feature is by default turned off. + * @param receivingSelectionEventsFromClient Whether or not the client should send events about text selection changes. + * @return This. + * @see #setReceivingSelectionEventsFromClient(boolean) + */ + @SuppressWarnings("unchecked") + default SELF withReceivingSelectionEventsFromClient(boolean receivingSelectionEventsFromClient) { + this.setReceivingSelectionEventsFromClient(receivingSelectionEventsFromClient); + return (SELF)this; + } + + +} diff --git a/superfields/src/main/resources/META-INF/resources/frontend/super-text-area.js b/superfields/src/main/resources/META-INF/resources/frontend/super-text-area.js new file mode 100644 index 00000000..32e5764e --- /dev/null +++ b/superfields/src/main/resources/META-INF/resources/frontend/super-text-area.js @@ -0,0 +1,15 @@ +import {TextAreaElement} from '@vaadin/vaadin-text-field/src/vaadin-text-area'; +import {TextSelectionMixin} from "./text-selection-mixin"; + +class SuperTextArea extends TextSelectionMixin.to(TextAreaElement) { + + static get is() {return 'super-text-area'} + + setCallingServer(callingServer) { + console.log('STA: configuring event listeners; callingServer flag is '+callingServer); + this.listenToEvents(this.inputElement, this, callingServer); + } + +} + +customElements.define(SuperTextArea.is, SuperTextArea); \ No newline at end of file diff --git a/superfields/src/main/resources/META-INF/resources/frontend/super-text-field.js b/superfields/src/main/resources/META-INF/resources/frontend/super-text-field.js new file mode 100644 index 00000000..87c0b44d --- /dev/null +++ b/superfields/src/main/resources/META-INF/resources/frontend/super-text-field.js @@ -0,0 +1,15 @@ +import {TextFieldElement} from '@vaadin/vaadin-text-field/src/vaadin-text-field'; +import {TextSelectionMixin} from "./text-selection-mixin"; + +class SuperTextField extends TextSelectionMixin.to(TextFieldElement) { + + static get is() {return 'super-text-field'} + + setCallingServer(callingServer) { + console.log('STF: configuring event listeners; callingServer flag is '+callingServer); + this.listenToEvents(this.inputElement, this, callingServer); + } + +} + +customElements.define(SuperTextField.is, SuperTextField); \ No newline at end of file diff --git a/superfields/src/main/resources/META-INF/resources/frontend/text-selection-mixin.js b/superfields/src/main/resources/META-INF/resources/frontend/text-selection-mixin.js new file mode 100644 index 00000000..3d469f5f --- /dev/null +++ b/superfields/src/main/resources/META-INF/resources/frontend/text-selection-mixin.js @@ -0,0 +1,69 @@ +export class TextSelectionMixin { + static to(superclass) { + return class extends superclass { + + updateData(data, src) { + const currentStart = data.startsAt; + const currentEnd = data.endsAt; + // there is some selection + if (data.input.selectionStart !== undefined && data.input.selectionStart !== data.input.selectionEnd) { + data.startsAt = data.input.selectionStart; + data.endsAt = data.input.selectionEnd; + data.selection = data.input.value.substring(data.startsAt, data.endsAt); + } + else { + data.startsAt = -1; + data.endsAt = -1; + data.selection = ''; + } + src.dataset.selectedText = data.selection; + if(data.callServer && (currentStart !== data.startsAt || currentEnd !== data.endsAt)) { + console.log('TSM: calling server'); + src.$server.selectionChanged(data.startsAt, data.endsAt, data.selection); + } + } + + selectAll(src) { + console.log('TSM: selecting all text'); + src.selectionMixin.input.select(); + src.updateData(src.selectionMixin, src); + } + + selectNone(src) { + console.log('TSM: selecting no text'); + src.selectionMixin.input.selectionStart = src.selectionMixin.input.selectionEnd; + src.updateData(src.selectionMixin, src) + } + + select(src, from, to) { + console.log('TSM: selecting from '+from+' to '+to); + if (from <= to) { + src.selectionMixin.input.selectionStart = from; + src.selectionMixin.input.selectionEnd = to; + src.updateData(src.selectionMixin, src); + } + } + + listenToEvents(inputComponent, webComponent, notifyServer) { + console.log('TSM: setting up text selection for component <'+webComponent.tagName+'>'); + if (webComponent.selectionMixin === undefined) { + webComponent.selectionMixin = { + input: inputComponent, + callServer: notifyServer, + startsAt: -1, + endsAt: -1, + selection: '' + } + + const listener = () => webComponent.updateData(webComponent.selectionMixin, webComponent); + inputComponent.addEventListener('mouseup', listener); + inputComponent.addEventListener('keyup', listener); + inputComponent.addEventListener('mouseleave', listener); + } + else { + webComponent.selectionMixin.callServer = notifyServer; + } + } + } + } +} \ No newline at end of file diff --git a/superfields/src/test/java/org/vaadin/miki/superfields/text/AbstractTestForTextSelection.java b/superfields/src/test/java/org/vaadin/miki/superfields/text/AbstractTestForTextSelection.java new file mode 100644 index 00000000..75dc74ce --- /dev/null +++ b/superfields/src/test/java/org/vaadin/miki/superfields/text/AbstractTestForTextSelection.java @@ -0,0 +1,61 @@ +package org.vaadin.miki.superfields.text; + +import com.github.mvysny.kaributesting.v10.MockVaadin; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.HasValue; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +abstract class AbstractTestForTextSelection & CanReceiveSelectionEventsFromClient & TextSelectionNotifier> { + + private C textComponent; + + private int eventCounter; + + private String lastSelectedText; + + protected abstract C constructComponent(); + + @Before + public void setUp() { + MockVaadin.setup(); + this.textComponent = this.constructComponent(); + this.textComponent.addTextSelectionListener(event -> { + eventCounter++; + lastSelectedText = event.getSelectedText(); + }); + this.eventCounter = 0; + } + + @After + public void tearDown() { + MockVaadin.tearDown(); + } + + // note: it is not possible to test client-side with karibu + @Test + public void testServerSideSelection() { + final String helloWorld = "hello, world!"; + Assert.assertFalse(this.textComponent.isReceivingSelectionEventsFromClient()); + this.textComponent.setValue(helloWorld); + this.textComponent.selectAll(); + Assert.assertEquals("text-selection should have been fired", 1, this.eventCounter); + Assert.assertEquals("all text should be selected in event", helloWorld, this.lastSelectedText); + Assert.assertEquals("all text should be selected in attribute", helloWorld, this.textComponent.getElement().getAttribute(TextSelectionDelegate.SELECTED_TEXT_ATTRIBUTE_NAME)); + this.textComponent.selectNone(); + Assert.assertEquals("text-selection should have been fired again", 2, this.eventCounter); + Assert.assertTrue("no text should be selected in event", this.lastSelectedText.isEmpty()); + Assert.assertTrue("no text should be selected in attribute", this.textComponent.getElement().getAttribute(TextSelectionDelegate.SELECTED_TEXT_ATTRIBUTE_NAME).isEmpty()); + this.textComponent.select(7, 12); + Assert.assertEquals("text-selection should have been fired again", 3, this.eventCounter); + Assert.assertEquals("some text should be selected in event", "world", this.lastSelectedText); + Assert.assertEquals("some text should be selected in attribute", "world", this.textComponent.getElement().getAttribute(TextSelectionDelegate.SELECTED_TEXT_ATTRIBUTE_NAME)); + this.textComponent.setValue("clear selection"); + Assert.assertEquals("text-selection should have been fired again", 4, this.eventCounter); + Assert.assertTrue("no text should be selected in event", this.lastSelectedText.isEmpty()); + Assert.assertTrue("no text should be selected in attribute", this.textComponent.getElement().getAttribute(TextSelectionDelegate.SELECTED_TEXT_ATTRIBUTE_NAME).isEmpty()); + } + +} diff --git a/superfields/src/test/java/org/vaadin/miki/superfields/text/SuperTextAreaTest.java b/superfields/src/test/java/org/vaadin/miki/superfields/text/SuperTextAreaTest.java new file mode 100644 index 00000000..ae3dde64 --- /dev/null +++ b/superfields/src/test/java/org/vaadin/miki/superfields/text/SuperTextAreaTest.java @@ -0,0 +1,8 @@ +package org.vaadin.miki.superfields.text; + +public class SuperTextAreaTest extends AbstractTestForTextSelection { + @Override + protected SuperTextArea constructComponent() { + return new SuperTextArea(); + } +} diff --git a/superfields/src/test/java/org/vaadin/miki/superfields/text/SuperTextFieldTest.java b/superfields/src/test/java/org/vaadin/miki/superfields/text/SuperTextFieldTest.java new file mode 100644 index 00000000..eef5802c --- /dev/null +++ b/superfields/src/test/java/org/vaadin/miki/superfields/text/SuperTextFieldTest.java @@ -0,0 +1,9 @@ +package org.vaadin.miki.superfields.text; + +public class SuperTextFieldTest extends AbstractTestForTextSelection { + + @Override + protected SuperTextField constructComponent() { + return new SuperTextField(); + } +} \ No newline at end of file