diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml new file mode 100644 index 0000000..039ddbf --- /dev/null +++ b/.github/workflows/maven.yml @@ -0,0 +1,57 @@ +# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Maven + +on: + workflow_dispatch: + + +jobs: + build-ubuntu: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 23 + uses: actions/setup-java@v4 + with: + java-version: '23' + distribution: 'temurin' + cache: maven + - name: Build with Maven + run: mvn -B package --file pom.xml + + # Optional: Uploads the full dependency graph to GitHub to improve the quality of Dependabot alerts this repository can receive + - name: Update dependency graph + uses: advanced-security/maven-dependency-submission-action@571e99aab1055c2e71a1e2309b9691de18d6b7d6 + + - name: Run J-Link + run: javafx:jlink -f pom.xml + + build-windows: + runs-on: windows + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 23 + uses: actions/setup-java@v4 + with: + java-version: '23' + distribution: 'temurin' + cache: maven + - name: Build with Maven + run: mvn -B package --file pom.xml + + # Optional: Uploads the full dependency graph to GitHub to improve the quality of Dependabot alerts this repository can receive + - name: Update dependency graph + uses: advanced-security/maven-dependency-submission-action@571e99aab1055c2e71a1e2309b9691de18d6b7d6 + + - name: Run J-Link + run: mvn javafx:jlink -f pom.xml diff --git a/README.md b/README.md index 397b277..317e3cc 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ -# Make your own app! - [appName] +# Make your own app! - Yet Another Collaborative Whiteboard App ## What is it ? -@torineos and @akomry's submission to `Make your own app!` game jam. -Probably a networking or multimedia app or something. +@akomry's submission to `Make your own app!` game jam. + +Basically, this is an online drawing app. It only has the most basic tools for now, pen and eraser with custom color, but I hope to be able to add more. ## Dependencies -You need at least **[Java 23](https://adoptium.net/temurin/releases/?version=23)** up and running. +Probably **[Java 23](https://adoptium.net/temurin/releases/?version=23)** up and running. ## Installation guide @@ -16,14 +17,20 @@ install a packaged zipped file. Then execute `[extract dir]/bin/app`. ## -Roadmap- - [x] Brainstorming -- [ ] Find a name -- [ ] List issues - +- [x] Find a name +- [x] List issues +- [x] Experiment with javafx Canvas +- [x] Implement Canvas creation +- [x] Implement TCP/IP server/client +- [x] Implement event callback +- [x] Implement brush, its size and color +- [x] Implement eraser +- [ ] Implement zoom control (WIP, sketchy zoom) +- [ ] Implement layering system ## License TBD ## Contributors -* @torineos * @akomry \ No newline at end of file diff --git a/graphical-app/pom.xml b/graphical-app/pom.xml index a1db254..6c8253f 100644 --- a/graphical-app/pom.xml +++ b/graphical-app/pom.xml @@ -9,20 +9,22 @@ 1.0-SNAPSHOT graphical-app + UTF-8 -5.10.0 + 5.10.0 + org.openjfx javafx-controls - 17.0.6 + 24 org.openjfx javafx-fxml - 17.0.6 + 24 @@ -36,14 +38,30 @@ junit-jupiter-engine ${junit.version} test - + + + org.controlsfx + controlsfx + 11.2.2 + + + net.synedra + validatorfx + 0.5.1 + + + org.json + json + 20240303 + + org.apache.maven.plugins maven-compiler-plugin - 3.11.0 + 3.14.0 23 23 @@ -55,10 +73,9 @@ 0.0.8 - default-cli - fr.emiko.graphicalapp/fr.emiko.graphicalapp.HelloApplication + fr.emiko.graphicalapp/fr.emiko.graphicalapp.DrawApplication app app app diff --git a/graphical-app/src/main/java/fr/emiko/graphicalapp/DrawApplication.java b/graphical-app/src/main/java/fr/emiko/graphicalapp/DrawApplication.java new file mode 100644 index 0000000..81eb4dc --- /dev/null +++ b/graphical-app/src/main/java/fr/emiko/graphicalapp/DrawApplication.java @@ -0,0 +1,27 @@ +package fr.emiko.graphicalapp; + +import javafx.application.Application; +import javafx.fxml.FXMLLoader; +import javafx.scene.Scene; +import javafx.scene.image.Image; +import javafx.stage.Stage; + +import javax.imageio.ImageIO; +import java.io.IOException; +import java.util.Objects; + +public class DrawApplication extends Application { + @Override + public void start(Stage stage) throws IOException { + FXMLLoader fxmlLoader = new FXMLLoader(DrawApplication.class.getResource("draw-view.fxml")); + Scene scene = new Scene(fxmlLoader.load(), 1280, 720); + stage.setTitle("Yet Another Collaborative Drawing App"); + stage.getIcons().add(new Image(Objects.requireNonNull(getClass().getResourceAsStream("icon.png")))); + stage.setScene(scene); + stage.show(); + } + + public static void main(String[] args) { + launch(); + } +} \ No newline at end of file diff --git a/graphical-app/src/main/java/fr/emiko/graphicalapp/DrawController.java b/graphical-app/src/main/java/fr/emiko/graphicalapp/DrawController.java new file mode 100644 index 0000000..96d5477 --- /dev/null +++ b/graphical-app/src/main/java/fr/emiko/graphicalapp/DrawController.java @@ -0,0 +1,491 @@ +package fr.emiko.graphicalapp; + +import fr.emiko.graphicsElement.Line; +import fr.emiko.graphicsElement.layerListViewCell; +import fr.emiko.net.DrawClient; +import fr.emiko.net.DrawServer; +import fr.emiko.net.Event; +import fr.emiko.net.User; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +import javafx.fxml.FXMLLoader; +import javafx.fxml.Initializable; +import javafx.scene.Scene; +import javafx.scene.canvas.Canvas; +import javafx.scene.control.*; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.effect.BoxBlur; +import javafx.scene.input.*; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Priority; +import javafx.scene.paint.Color; +import fr.emiko.graphicsElement.Stroke; +import javafx.scene.transform.Scale; +import javafx.stage.Modality; +import javafx.stage.Stage; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.URL; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class DrawController implements Initializable { + private final Pattern hostPortPattern = Pattern.compile("^([-.a-zA-Z0-9]+)(?::([0-9]{1,5}))?$"); + public Canvas drawingCanvas; + public MenuItem saveButton; + public MenuItem loadButton; + public MenuItem newCanvasButton; + public Slider brushSizeSlider; + public ScrollPane scrollPane; + public Label brushSizeLabel; + public Pane pane; + public MenuItem hostButton; + public MenuItem joinButton; + public MenuItem disconnectButton; + public SplitPane mainPane; + public MenuItem stopHostButton; + public ColorPicker colorPicker; + public ListView layerListView; + public Button addLayerButton; + public Button removeLayerButton; + public MenuItem aboutMenuItem; + public Label statusLabel; + private double posX = 0; + private double posY = 0; + private double mouseX = 0; + private double mouseY = 0; + private Vector strokes = new Vector<>(); + private Vector lastSaved = new Vector<>(); + private Vector lines = new Vector<>(); + private User user; + private boolean connected; + private DrawClient client; + private ToggleButton hostButtonToggle = new ToggleButton(); + private DrawServer server; + private ObservableList layerObservableList = FXCollections.observableArrayList(); + + + @Override + public void initialize(URL url, ResourceBundle resourceBundle) { + saveButton.setOnAction(this::onActionSave); + loadButton.setOnAction(this::onActionLoad); + newCanvasButton.setOnAction(this::onActionCreateCanvas); + scrollPane.setOnScroll(this::onScrollZoom); + scrollPane.setOnKeyPressed(this::onActionKeyPressed); + brushSizeLabel.textProperty().bind(brushSizeSlider.valueProperty().asString()); + setupCanvas(drawingCanvas); + scrollPane.prefViewportHeightProperty().bind(pane.layoutYProperty()); + scrollPane.prefViewportWidthProperty().bind(pane.layoutXProperty()); + + stopHostButton.setOnAction(this::onActionStopHost); + hostButton.setOnAction(this::onActionHost); + joinButton.setOnAction(this::onActionJoin); + disconnectButton.setOnAction(this::onActionDisconnect); + + newCanvasButton.disableProperty().bind(hostButtonToggle.selectedProperty().not()); + stopHostButton.disableProperty().bind(hostButtonToggle.selectedProperty().not()); + disconnectButton.disableProperty().bind(hostButtonToggle.selectedProperty().not()); + hostButtonToggle.setSelected(false); + mainPane.disableProperty().bind(hostButtonToggle.selectedProperty().not()); + + layerListView.setCellFactory(layerListView -> new layerListViewCell()); + layerListView.setItems(layerObservableList); + layerListView.getSelectionModel().setSelectionMode(SelectionMode.SINGLE); + //addLayerButton.setOnAction(this::onActionAddLayer); + //removeLayerButton.setOnAction(this::onActionRemoveLayer); + //layerListView.setOnMouseClicked(this::onActionSelectCanvas); + + aboutMenuItem.setOnAction(this::onActionAbout); + } + + private void onActionAbout(ActionEvent actionEvent) { + Alert alert = new Alert(Alert.AlertType.INFORMATION); + alert.setTitle("About Yet Another Whiteboard App"); + alert.setHeaderText(null); + alert.setContentText("Hey ! This is my first ever game jam. :D \n" + "I made this in my free time, though a lot of things " + + "still don't work. This is basically yet another collaborative whiteboard app, you can host, someone can" + + " join, create a canvas and draw lines. For now, it works pretty horrendously; but i'll keep maintaining it " + + "and try to make something functional. \nFor now, the layers system doesn't work."); + alert.showAndWait(); + } + + private void onActionSelectCanvas(MouseEvent mouseEvent) { + layerListView.getSelectionModel().getSelectedItem().requestFocus(); + layerListView.getSelectionModel().getSelectedItem().toFront(); + } + + private void onActionRemoveLayer(ActionEvent actionEvent) { + pane.getChildren().remove(layerListView.getSelectionModel().getSelectedItem()); + layerObservableList.remove(layerListView.getSelectionModel().getSelectedItem()); + layerListView.refresh(); + layerListView.getSelectionModel().select(layerObservableList.getFirst()); + } + + private void onActionAddLayer(ActionEvent actionEvent) { + Canvas newLayer = new Canvas( + layerListView.getSelectionModel().getSelectedItem().getWidth(), + layerListView.getSelectionModel().getSelectedItem().getHeight() + ); + pane.getChildren().add(newLayer); + layerObservableList.addFirst(newLayer); + layerListView.getSelectionModel().select(newLayer); + setupCanvas(newLayer); + layerListView.refresh(); + } + + private void onActionStopHost(ActionEvent actionEvent) { + client.close(); + if (this.server != null) { + try { + server.close(); + } catch (IOException e) { + showErrorDialog(e, "Could not close server instance"); + } + } + hostButtonToggle.setSelected(false); + } + + private void onActionDisconnect(ActionEvent actionEvent) { + client.close(); + statusLabel.setText("Disconnected"); + hostButtonToggle.setSelected(false); + } + + + private void onActionJoin(ActionEvent actionEvent) { + TextInputDialog dialog = new TextInputDialog(); + dialog.setTitle("Join"); + dialog.setHeaderText(null); + dialog.setContentText("Enter distant address"); + Optional result = dialog.showAndWait(); + if (result.isPresent()) { + try { + Matcher matcher = hostPortPattern.matcher(result.get()); + matcher.matches(); + String host = matcher.group(1); + String port = matcher.group(2); + int finalPort = port == null ? 8090 : Integer.parseInt(port); + connectClient(host, finalPort); + client.sendEvent(new Event(Event.LINELST, new JSONObject())); + } catch (NumberFormatException e) { + showErrorDialog(e, "Invalid distant address"); + } catch (IOException e) { + showErrorDialog(e, "Could not connect to host"); + } + } + } + + private void onActionHost(ActionEvent actionEvent) { + TextInputDialog dialog = new TextInputDialog(); + dialog.setTitle("Host"); + dialog.setContentText("Which port do you want to use? (default: 8090)"); + Optional result = dialog.showAndWait(); + if (result.isPresent()) { + + try { + server = new DrawServer(result.get().isEmpty() ? 8090 : Integer.parseInt(result.get())); + Thread thread = new Thread(server::acceptClients); + thread.setDaemon(true); + thread.start(); + connectClient("localhost", result.get().isEmpty() ? 8090 : Integer.parseInt(result.get())); + } catch (NumberFormatException | IOException e) { + showErrorDialog(e, "Invalid port number"); + } + } + } + + private void connectClient(String host, int port) throws IOException { + this.client = new DrawClient(host, port, this); + hostButtonToggle.setSelected(true); + client.sendAuthEvent(String.valueOf(new Random().nextInt())); + statusLabel.setText("Connected to %s:%d".formatted(host, port)); + } + + private void showErrorDialog(Exception ex, String context) { + Alert alert = new Alert(Alert.AlertType.ERROR); + alert.setTitle("An error occured!"); + alert.setHeaderText(null); + alert.setContentText(context); + + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + ex.printStackTrace(pw); + String exceptionText = sw.toString(); + + Label label = new Label("The exception stacktrace was:"); + + TextArea textArea = new TextArea(exceptionText); + textArea.setEditable(false); + textArea.setWrapText(true); + + textArea.setMaxWidth(Double.MAX_VALUE); + textArea.setMaxHeight(Double.MAX_VALUE); + GridPane.setVgrow(textArea, Priority.ALWAYS); + GridPane.setHgrow(textArea, Priority.ALWAYS); + + GridPane expContent = new GridPane(); + expContent.setMaxWidth(Double.MAX_VALUE); + expContent.add(label, 0, 0); + expContent.add(textArea, 0, 1); + + alert.getDialogPane().setExpandableContent(expContent); + + alert.showAndWait(); + } + + private void setupCanvas(Canvas canvas) { + canvas.requestFocus(); + canvas.getGraphicsContext2D().setFill(Color.WHITE); + canvas.getGraphicsContext2D().fillRect(0, 0, drawingCanvas.getWidth(), drawingCanvas.getHeight()); + brushSizeSlider.setValue(1); +// canvas.setTranslateX(scrollPane.getWidth()/2); +// canvas.setTranslateY(scrollPane.getHeight()/2); + colorPicker.setValue(Color.BLACK); + canvas.setOnMouseDragged(this::printLine); + canvas.setOnMouseClicked(this::resetPos); + + layerListView.getSelectionModel().select(drawingCanvas); + layerObservableList.add(drawingCanvas); + layerListView.refresh(); + scrollPane.addEventFilter(ScrollEvent.ANY, new EventHandler() { + @Override + public void handle(ScrollEvent event) { + onScrollZoom(event); + event.consume(); + }}); + BoxBlur blur = new BoxBlur(); + blur.setHeight(1); + blur.setWidth(1); + blur.setIterations(1); + drawingCanvas.getGraphicsContext2D().setEffect(blur); + } + + private void onActionKeyPressed(KeyEvent keyEvent) { + keyEvent.consume(); + if (keyEvent.isControlDown() && keyEvent.getCode().equals(KeyCode.Z)) { + System.out.println("CTRL Z"); + System.out.println(lines); + System.out.println(lines); + lines.remove(lines.lastElement()); + Canvas currentLayer = layerListView.getSelectionModel().getSelectedItem(); + GraphicsContext gc = currentLayer.getGraphicsContext2D(); + gc.setFill(Color.WHITE); + gc.fillRect(0, 0, currentLayer.getWidth(), currentLayer.getHeight()); + gc.clearRect(0, 0, currentLayer.getWidth(), currentLayer.getHeight()); + gc.fill(); + for (Vector strokeVector : lines) { + for (Stroke stroke: strokeVector) { + stroke.draw(gc, stroke.getColor()); + //System.out.println(stroke); + } + } + } + if (keyEvent.isControlDown() && keyEvent.getCode().equals(KeyCode.Y)) { + System.out.println("CTRL Y"); + } + } + + private void onScrollZoom(ScrollEvent event) { + + event.consume(); + double SCALE_DELTA = 1.1; + if (event.getDeltaY() == 0) { + return; + } + if (event.isControlDown()) { + double scaleFactor = + (event.getDeltaY() > 0) ? SCALE_DELTA : 1 / SCALE_DELTA; + + + Scale newScale = new Scale(); + newScale.setX(drawingCanvas.getScaleX() * scaleFactor); + newScale.setY(drawingCanvas.getScaleY() * scaleFactor); + newScale.setPivotX(drawingCanvas.getScaleX()); + newScale.setPivotY(drawingCanvas.getScaleY()); + drawingCanvas.getTransforms().add(newScale); + + pane.setPrefHeight(pane.getHeight()*scaleFactor); + pane.setPrefWidth(pane.getWidth()*scaleFactor); + } + } + + private void onActionCreateCanvas(ActionEvent actionEvent) { + try { + NewCanvasController controller = showNewStage("New canvas...", "new-canvas-view.fxml"); + + if (controller.isOk()) { + //drawingCanvas = new Canvas(controller.getCanvasWidth(), controller.getCanvasHeight()); + //setupCanvas(); + layerObservableList.clear(); + drawingCanvas.setWidth(controller.getCanvasWidth()); + drawingCanvas.setHeight(controller.getCanvasHeight()); + clearDrawingCanvas(); + client.sendEvent(new Event(Event.ADDCANVAS, new JSONObject().put("width", drawingCanvas.getWidth()).put("height", drawingCanvas.getHeight()))); + System.out.println("New canvas created"); + } + } catch (IOException ignored) { + } + + } + + public T showNewStage(String title, String fxmlFileName) throws IOException { + FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource(fxmlFileName)); + Scene scene = new Scene(fxmlLoader.load()); + Stage stage = new Stage(); + stage.initModality(Modality.APPLICATION_MODAL); + stage.setTitle(title); + stage.setResizable(false); + stage.setScene(scene); + stage.showAndWait(); + return fxmlLoader.getController(); + } + + private void onActionLoad(ActionEvent actionEvent) { +// drawingCanvas.getGraphicsContext2D().drawImage(lastSaved, 0, 0); + GraphicsContext gc = drawingCanvas.getGraphicsContext2D(); + gc.clearRect(0, 0, drawingCanvas.getWidth(), drawingCanvas.getHeight()); + gc.setFill(Color.WHITE); + gc.fillRect(0, 0, drawingCanvas.getWidth(), drawingCanvas.getHeight()); + System.out.println(lastSaved.size()); + for (Vector strokeVector : lastSaved) { + for (Stroke stroke: strokeVector) { + stroke.draw(gc, colorPicker.getValue()); + System.out.println(stroke); + } + } + strokes = (Vector) lastSaved.clone(); + } + + private void onActionSave(ActionEvent actionEvent) { + GraphicsContext gc = drawingCanvas.getGraphicsContext2D(); + lastSaved = (Vector) lines.clone(); + System.out.println(lastSaved.size()); + } + + private void resetPos(MouseEvent mouseEvent) { + posX = 0; + posY = 0; + mouseX = 0; + mouseY = 0; + Line line = new Line(); + for (Stroke stroke: strokes) { + line.add(stroke); + } + lines.add((Line) line.clone()); + System.out.println(lines.size()); + System.out.println(lines); + System.out.println(new Event("ADDLINE", line.toJSONObject())); + strokes.clear(); + + client.sendEvent(new Event(Event.ADDLINE, line.toJSONObject())); + } + + private void printLine(MouseEvent mouseEvent) { + Canvas currentLayer = layerListView.getSelectionModel().getSelectedItem(); + + GraphicsContext gc = currentLayer.getGraphicsContext2D(); + + if (posX == 0 || posY == 0) { + posX = mouseEvent.getX(); + posY = mouseEvent.getY(); + } + + Stroke stroke = new Stroke(posX, posY, mouseEvent.getX(), mouseEvent.getY(), brushSizeSlider.getValue(), colorPicker.getValue()); + strokes.add(stroke); + + if (mouseEvent.isPrimaryButtonDown()) { + stroke.draw(gc, colorPicker.getValue()); + + posX = mouseEvent.getX(); + posY = mouseEvent.getY(); + + + } else if (mouseEvent.isSecondaryButtonDown()) { + stroke.draw(gc, Color.WHITE); + + posX = mouseEvent.getX(); + posY = mouseEvent.getY(); + } + } + + public void handleEvent(Event event) { + System.out.println("Received new event !:" + event.toJSON()); + String type = event.getType(); + switch (type) { + case Event.LINE -> { + doImportLine(event.getContent()); + } + case Event.DELLINE -> { + doDeleteLine(event.getContent()); + } + case Event.CNVS -> { + doAddCanvas(event.getContent()); + } + default -> {} + } + } + + private void doAddCanvas(JSONObject content) { + layerObservableList.clear(); + drawingCanvas.setWidth(content.getDouble("width")); + drawingCanvas.setHeight(content.getDouble("height")); + clearDrawingCanvas(); + + setupCanvas(drawingCanvas); + } + + private void clearDrawingCanvas() { + drawingCanvas.getGraphicsContext2D().setFill(Color.WHITE); + drawingCanvas.getGraphicsContext2D().fillRect(0, 0, drawingCanvas.getWidth(), drawingCanvas.getHeight()); + drawingCanvas.getGraphicsContext2D().fill(); + pane.setScaleX(1); + pane.setScaleY(1); + layerObservableList.add(drawingCanvas); + layerListView.refresh(); + } + + private void doDeleteLine(JSONObject content) { + lines.remove(Line.fromJSONArray(content.getJSONArray("line"))); + + GraphicsContext gc = drawingCanvas.getGraphicsContext2D(); + lines.sort(new Comparator() { + @Override + public int compare(Line o1, Line o2) { + return Integer.compare(o2.getTimestamp(), o1.getTimestamp()); + } + }); + for (Line line: lines) { + for (Stroke stroke: line) { + stroke.draw(gc, colorPicker.getValue()); + } + } + + } + + private void doImportLine(JSONObject content) { + Line importedLine = Line.fromJSONArray(content.getJSONArray("line")); + this.lines.add(importedLine); + GraphicsContext gc = drawingCanvas.getGraphicsContext2D(); + gc.clearRect(0, 0, drawingCanvas.getWidth(), drawingCanvas.getHeight()); + lines.sort(new Comparator() { + @Override + public int compare(Line o1, Line o2) { + if (o1.getTimestamp() < o2.getTimestamp()) { + return 1; + } else { + return 0; + } + } + }); + for (Stroke stroke: importedLine) { + stroke.draw(gc, stroke.getColor()); + } + } +} \ No newline at end of file diff --git a/graphical-app/src/main/java/fr/emiko/graphicalapp/HelloApplication.java b/graphical-app/src/main/java/fr/emiko/graphicalapp/HelloApplication.java deleted file mode 100644 index 3ed2999..0000000 --- a/graphical-app/src/main/java/fr/emiko/graphicalapp/HelloApplication.java +++ /dev/null @@ -1,23 +0,0 @@ -package fr.emiko.graphicalapp; - -import javafx.application.Application; -import javafx.fxml.FXMLLoader; -import javafx.scene.Scene; -import javafx.stage.Stage; - -import java.io.IOException; - -public class HelloApplication extends Application { - @Override - public void start(Stage stage) throws IOException { - FXMLLoader fxmlLoader = new FXMLLoader(HelloApplication.class.getResource("hello-view.fxml")); - Scene scene = new Scene(fxmlLoader.load(), 320, 240); - stage.setTitle("Hello!"); - stage.setScene(scene); - stage.show(); - } - - public static void main(String[] args) { - launch(); - } -} \ No newline at end of file diff --git a/graphical-app/src/main/java/fr/emiko/graphicalapp/HelloController.java b/graphical-app/src/main/java/fr/emiko/graphicalapp/HelloController.java deleted file mode 100644 index 63b4e52..0000000 --- a/graphical-app/src/main/java/fr/emiko/graphicalapp/HelloController.java +++ /dev/null @@ -1,14 +0,0 @@ -package fr.emiko.graphicalapp; - -import javafx.fxml.FXML; -import javafx.scene.control.Label; - -public class HelloController { - @FXML - private Label welcomeText; - - @FXML - protected void onHelloButtonClick() { - welcomeText.setText("Welcome to JavaFX Application!"); - } -} \ No newline at end of file diff --git a/graphical-app/src/main/java/fr/emiko/graphicalapp/NewCanvasController.java b/graphical-app/src/main/java/fr/emiko/graphicalapp/NewCanvasController.java new file mode 100644 index 0000000..5c78008 --- /dev/null +++ b/graphical-app/src/main/java/fr/emiko/graphicalapp/NewCanvasController.java @@ -0,0 +1,70 @@ +package fr.emiko.graphicalapp; + +import javafx.event.ActionEvent; +import javafx.fxml.Initializable; +import javafx.scene.control.Button; +import javafx.scene.control.TextField; +import javafx.stage.Stage; +import net.synedra.validatorfx.Check; +import net.synedra.validatorfx.Validator; + +import java.net.URL; +import java.util.ResourceBundle; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class NewCanvasController implements Initializable { + public TextField heightTextField; + public TextField widthTextField; + public Button createButton; + public Button cancelButton; + private double canvasWidth; + private double canvasHeight; + private boolean ok = false; + private Validator validator = new Validator(); + @Override + public void initialize(URL url, ResourceBundle resourceBundle) { + createButton.setOnAction(this::create); + createButton.disableProperty().bind(validator.containsErrorsProperty()); + cancelButton.setOnAction(this::close); + widthTextField.setOnAction(this::create); + validator.createCheck() + .decorates(createButton) + .dependsOn("width", widthTextField.textProperty()) + .dependsOn("height", heightTextField.textProperty()) + .withMethod(this::checkWidthHeight) + .immediate(); + } + + private void checkWidthHeight(Check.Context context) { + Pattern pattern = Pattern.compile("\\d+"); + Matcher widthMatcher = pattern.matcher(widthTextField.getText()); + Matcher heightMatcher = pattern.matcher(heightTextField.getText()); + if (!widthMatcher.matches() || !heightMatcher.matches()) { + context.error("Width and height fields must contain only numbers."); + } + } + + public double getCanvasWidth() { + return canvasWidth; + } + + public double getCanvasHeight() { + return canvasHeight; + } + + public boolean isOk() { + return ok; + } + + private void close(ActionEvent actionEvent) { + ((Stage) createButton.getScene().getWindow()).close(); + } + + private void create(ActionEvent actionEvent) { + this.ok = true; + this.canvasWidth = Double.parseDouble(widthTextField.getText()); + this.canvasHeight = Double.parseDouble(heightTextField.getText()); + ((Stage) createButton.getScene().getWindow()).close(); + } +} diff --git a/graphical-app/src/main/java/fr/emiko/graphicsElement/Line.java b/graphical-app/src/main/java/fr/emiko/graphicsElement/Line.java new file mode 100644 index 0000000..6b332b7 --- /dev/null +++ b/graphical-app/src/main/java/fr/emiko/graphicsElement/Line.java @@ -0,0 +1,35 @@ +package fr.emiko.graphicsElement; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.UUID; +import java.util.Vector; + +public class Line extends Vector { + private int timestamp; + + public JSONObject toJSONObject() { + JSONArray jsonArray = new JSONArray(); + for (Stroke stroke: this) { + jsonArray.put(stroke.toJSON()); + } + return new JSONObject().put("line", jsonArray).put("timestamp", timestamp); + } + + public static Line fromJSONArray(JSONArray jsonArray) { + Line line = new Line(); + for (int i = 0; i < jsonArray.length(); i++) { + line.add(Stroke.fromJSON(jsonArray.getString(i))); + } + return line; + } + + public int getTimestamp() { + return timestamp; + } + + public void setTimestamp(int timestamp) { + this.timestamp = timestamp; + } +} diff --git a/graphical-app/src/main/java/fr/emiko/graphicsElement/Stroke.java b/graphical-app/src/main/java/fr/emiko/graphicsElement/Stroke.java new file mode 100644 index 0000000..ecfcf5b --- /dev/null +++ b/graphical-app/src/main/java/fr/emiko/graphicsElement/Stroke.java @@ -0,0 +1,88 @@ +package fr.emiko.graphicsElement; + +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.paint.Color; +import javafx.scene.shape.Path; +import javafx.scene.shape.StrokeLineCap; +import javafx.scene.shape.StrokeLineJoin; +import org.json.JSONObject; + +import java.awt.*; +import java.util.Objects; + +public class Stroke { + private final double fromX; + private final double fromY; + private final double toX; + private final double toY; + private final double brushSize; + private final Color color; + + public Stroke (double fromX, double fromY, double toX, double toY, double brushSize, Color color) { + this.fromX = fromX; + this.fromY = fromY; + this.toX = toX; + this.toY = toY; + this.brushSize = brushSize; + this.color = color; + } + + public static Stroke fromJSON(String jsonStroke) { + JSONObject jsonObject = new JSONObject(jsonStroke); + return new Stroke( + jsonObject.getDouble("fromX"), + jsonObject.getDouble("fromY"), + jsonObject.getDouble("toX"), + jsonObject.getDouble("toY"), + jsonObject.getDouble("brushSize"), + Color.valueOf(jsonObject.get("color").toString()) + ); + } + + public void draw (GraphicsContext g, javafx.scene.paint.Color color) { + g.setStroke(color); + g.setLineCap(StrokeLineCap.ROUND); + g.setMiterLimit(1); + g.setLineWidth(brushSize); + g.setLineJoin(StrokeLineJoin.ROUND); + g.beginPath(); + g.moveTo(fromX, fromY); + g.lineTo(toX, toY); + g.closePath(); + g.stroke(); + g.fill(); + } + + @Override + public String toString () { +// return "Stroke{fromX=%f, fromY=%f, toX=%f, toY=%f, brushSize=%f}".formatted(fromX, fromY, toX, toY, brushSize); + return this.toJSON(); + } + + public JSONObject toJSONObject() { + return new JSONObject() + .put("fromX", fromX) + .put("fromY", fromY) + .put("toX", toX) + .put("toY", toY) + .put("brushSize", brushSize) + .put("color", color); + } + + public String toJSON() { + return toJSONObject().toString(); + } + + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Stroke stroke = (Stroke) o; + return Double.compare(fromX, stroke.fromX) == 0 && Double.compare(fromY, stroke.fromY) == 0 && Double.compare(toX, stroke.toX) == 0 && Double.compare(toY, stroke.toY) == 0 && Double.compare(brushSize, stroke.brushSize) == 0; + } + + public Color getColor() { + return this.color; + } +} diff --git a/graphical-app/src/main/java/fr/emiko/graphicsElement/layerListViewCell.java b/graphical-app/src/main/java/fr/emiko/graphicsElement/layerListViewCell.java new file mode 100644 index 0000000..796e358 --- /dev/null +++ b/graphical-app/src/main/java/fr/emiko/graphicsElement/layerListViewCell.java @@ -0,0 +1,34 @@ +package fr.emiko.graphicsElement; + +import javafx.geometry.Pos; +import javafx.scene.control.ListCell; +import javafx.scene.canvas.Canvas; +import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; +import javafx.scene.text.Text; + +public class layerListViewCell extends ListCell { + + @Override + protected void updateItem(Canvas item, boolean empty) { + super.updateItem(item, empty); + if (empty) { + setGraphic(null); + } else { + updateItem(item); + } + } + + + private void updateItem(Canvas item) { + ImageView imageView = new ImageView(); + imageView.setImage(item.snapshot(null, null)); + imageView.setFitHeight(25); + imageView.setFitWidth(25); + Text text = new Text(item.toString()); + HBox hbox = new HBox(imageView, text); + hbox.setSpacing(10); + hbox.setAlignment(Pos.CENTER_LEFT); + setGraphic(hbox); + } +} diff --git a/graphical-app/src/main/java/fr/emiko/net/ClientTCP.java b/graphical-app/src/main/java/fr/emiko/net/ClientTCP.java new file mode 100644 index 0000000..5a5740b --- /dev/null +++ b/graphical-app/src/main/java/fr/emiko/net/ClientTCP.java @@ -0,0 +1,175 @@ +package fr.emiko.net; + +import java.io.*; +import java.net.Socket; +import java.nio.charset.StandardCharsets; + + +/** + * Client TCP : envoie des chaines de caractères à un serveur et lit les chaines en retour. + */ +public class ClientTCP { + /** Couleur rouge */ + public static final String RED = "\u001b[31m"; + /** Couleur bleue */ + public static final String BLUE = "\u001b[34m"; + /** Couleur standard */ + public static final String RST = "\u001b[0m"; + /** Fin de message */ + public static final String END_MESSAGE = "fin"; + + /** + * Socket connecté au serveur + */ + protected Socket sock; + + /** + * Flux de caractères UTF-8 en sortie + */ + protected PrintStream out; + + /** + * Flux de caractères UTF-8 en entrée + */ + protected BufferedReader in; + + /** + * Chaine de caractères "ip:port" du client + */ + protected String ipPort; + + /** + * Le client est-il connecté ? + */ + protected boolean connected; + + + /** + * Programme principal [Déprécié] + * @param args Arguments + * @throws Exception Si la connexion échoue + */ + public static void main(String[] args) throws Exception { + + ClientTCP client = new ClientTCP("localhost", 2024); + Thread envoi = new Thread(client::sendLoop); + Thread reception = new Thread(client::receiveLoop); + envoi.start(); + reception.start(); + envoi.join(); + client.close(); + } + + /** + * Le constructeur ouvre la connexion TCP au serveur host:port + * et récupère les flux de caractères en entrée {@link #in} et sortie {@link #out} + *import static rtgre.chat.ChatApplication.LOGGER; + * @param host IP ou nom de domaine du serveur + * @param port port d'écoute du serveur + * @throws IOException si la connexion échoue ou si les flux ne sont pas récupérables + */ + public ClientTCP(String host, int port) throws IOException { + sock = new Socket(host, port); + ipPort = "%s:%d".formatted(sock.getLocalAddress().getHostAddress(), sock.getLocalPort()); + this.connected = true; + OutputStream os = sock.getOutputStream(); + InputStream is = sock.getInputStream(); + out = new PrintStream(os, true, StandardCharsets.UTF_8); + in = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8), 2048); + Thread rcLoop = new Thread(this::receiveLoop); + rcLoop.setDaemon(true); + rcLoop.start(); + } + + /** + * Envoie une chaine de caractères + * + * @param message chaine de caractères à transmettre + * @throws IOException lorsqu'une erreur sur le flux de sortie est détectée + */ + public void send(String message) throws IOException { + out.println(message); + if (out.checkError()) { + throw new IOException("Output stream error"); + } + } + + /** + * Getter de connected + * @return L'état de connected + */ + public boolean isConnected() { + return connected; + } + + /** + * Setter de connected + * @param connected L'utilisateur est-il connecté ? + */ + public void setConnected(boolean connected) { + this.connected = connected; + } + + /** + * Attente d'une chaine de caractères en entrée. + * + * @return chaine de caractères reçue + * @throws IOException lorsque la fin du flux est atteinte + */ + public String receive() throws IOException { + String message = in.readLine(); + if (message == null) { + throw new IOException("End of the stream has been reached"); + } + return message; + } + + /** + * Fermeture de la connexion TCP + */ + public void close() { + try { + sock.close(); + this.connected = false; + } catch (IOException e) { + } + } + + + /** + * Boucle d'envoi de messages + */ + public void sendLoop() { + BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in)); + connected = true; + try { + while (connected) { + String message = stdIn.readLine(); + if (message == null) { // fin du flux stdIn + message = END_MESSAGE; + } + this.send(message); + if (END_MESSAGE.equals(message)) { + connected = false; + } + } + } catch (IOException e) { + connected = false; + } + } + + /** + * Boucle de réception de messages + */ + public void receiveLoop() { + connected = true; + try { + while (connected) { + String message = this.receive(); + System.out.println("Message received: " + message); + } + } catch (IOException e) { + connected = false; + } + } +} \ No newline at end of file diff --git a/graphical-app/src/main/java/fr/emiko/net/DrawClient.java b/graphical-app/src/main/java/fr/emiko/net/DrawClient.java new file mode 100644 index 0000000..6604397 --- /dev/null +++ b/graphical-app/src/main/java/fr/emiko/net/DrawClient.java @@ -0,0 +1,68 @@ +package fr.emiko.net; + +import fr.emiko.graphicalapp.DrawController; +import javafx.application.Platform; +import org.json.JSONObject; + +import java.io.IOException; + +public class DrawClient extends ClientTCP{ + private final DrawController listener; + + public DrawClient(String host, int port, DrawController listener) throws IOException { + super(host, port); + this.listener = listener; + } + + + + /** + * Envoi d'un évènement, sérialisé dans sa représentation JSON, au serveur. + * @param event L'évènement à envoyer + */ + public void sendEvent(Event event) { + connected = true; + try { + String message = event.toJSON(); + if (message == null) { // fin du flux stdIn + message = END_MESSAGE; + } + this.send(message); + if (END_MESSAGE.equals(message)) { + connected = false; + } + } catch (IOException e) { + connected = false; + } + } + + + /** + * Boucle de réception des messages : chaque message est un évènement sérialisé en JSON, qui est transféré à ChatController.handleEvent(rtgre.modeles.Event) pour traitement. + * Si le message n'est pas conforme (format JSON), la connection est stoppée. + */ + @Override + public void receiveLoop() { + try { + while (connected) { + String message = this.receive(); + if (listener != null) { + Platform.runLater(() -> listener.handleEvent(Event.fromJSON(message))); + } + } + } catch (IOException e) { + connected = false; + } finally { + close(); + } + } + + + public void sendAuthEvent(String login) { + try { + this.send(new Event(Event.AUTH, new JSONObject().put("username", login)).toJSON()); + } catch (IOException e) { + e.printStackTrace(); + } + } +} diff --git a/graphical-app/src/main/java/fr/emiko/net/DrawServer.java b/graphical-app/src/main/java/fr/emiko/net/DrawServer.java new file mode 100644 index 0000000..b8346a7 --- /dev/null +++ b/graphical-app/src/main/java/fr/emiko/net/DrawServer.java @@ -0,0 +1,254 @@ +package fr.emiko.net; + +import java.awt.*; +import java.io.*; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Vector; + +import fr.emiko.graphicsElement.Line; +import org.json.JSONException; +import org.json.JSONObject; + +public class DrawServer { + + private ServerSocket passiveSocket; + private Vector clientList = new Vector(); + private Vector lines; + private double canvasWidth; + private double canvasHeight; + public DrawServer(int port) throws IOException { + passiveSocket = new ServerSocket(port); + } + + + public static void main(String[] args) throws IOException { + DrawServer server = new DrawServer(8090); + server.acceptClients(); + } + + public void acceptClients() { + while (true) { + try { + Socket sock = passiveSocket.accept(); + handleNewClient(sock); + } catch (IOException e) { + System.out.println(e); + } + } + + } + + + public void removeClient(DrawClientHandler client) { + clientList.remove(client); + } + + + private void handleNewClient(Socket sock) throws IOException { + DrawClientHandler client = new DrawClientHandler(sock); + clientList.add(client); + + Thread clientLoop = new Thread(client::eventReceiveLoop); + clientLoop.start(); + } + + + /** + * Ferme la connexion du serveur, en fermant la connexion auprès de tous ses clients, puis en fermant son socket en écoute passive. + * @throws IOException si la connexion + */ + public void close() throws IOException { + for (DrawClientHandler client : clientList) { + client.close(); + } + passiveSocket.close(); + } + + + + private class DrawClientHandler { + + /** Message de fin d'une connexion */ + public static final String END_MESSAGE = "fin"; + /** + * Socket connecté au client + */ + private Socket sock; + /** + * Flux de caractères en sortie + */ + private PrintStream out; + /** + * Flux de caractères en entrée + */ + private BufferedReader in; + /** + * Chaine de caractères "ip:port" du client + */ + private String ipPort; + private User user; + + /** + * Initialise les attributs {@link #sock} (socket connecté au client), + * {@link #out} (flux de caractères UTF-8 en sortie) et + * {@link #in} (flux de caractères UTF-8 en entrée). + * + * @param sock socket connecté au client + * @throws IOException si la connexion ne peut être établie ou si les flux ne peuvent être récupérés + */ + public DrawClientHandler(Socket sock) throws IOException { + this.sock = sock; + this.ipPort = "%s:%d".formatted(sock.getInetAddress().getHostAddress(), sock.getPort()); + OutputStream os = sock.getOutputStream(); + InputStream is = sock.getInputStream(); + this.out = new PrintStream(os, true, StandardCharsets.UTF_8); + this.in = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8)); + } + + + /** + * Boucle de réception d'évènement : réceptionne les messages reçus et les délèguent à `handleEvent(java.lang.String)` pour les interpréter + */ + public void eventReceiveLoop() { + try { + String message = null; + while (!END_MESSAGE.equals(message)) { + message = in.readLine(); + if (message == null) { + break; + } + System.out.println("Réception de message : " + message); + try { + if (!handleEvent(message)) { + break; + } + } catch (Exception e) { + System.out.println(e.getMessage()); + break; + } + } + } catch (IOException e) { + System.out.println(e.getMessage()); + } + close(); + } + + + + /** + * Traitement d'un évènement. Ventile vers les méthodes traitant chaque type d'évènement. + * @param message objet évènement sous la forme d'une chaine JSON brute de réception + * @return `false` si l'évènement est de type Event.QUIT , `true` pour tous les autres types. + * @throws JSONException si l'objet JSON n'est pas conforme + * @throws IllegalStateException si l'authentification n'est pas effectuée + */ + private boolean handleEvent(String message) throws JSONException, IllegalStateException { + Event event = Event.fromJSON(message); + switch (event.getType()) { + case Event.AUTH -> { + doLogin(event.getContent()); + return true; + } + case Event.ADDLINE -> { + doAddLine(event.getContent()); + return true; + } + case Event.DELLINE -> { + doDelLine(event.getContent()); + return true; + } + case Event.LINELST -> { + doSendLines(); + return true; + } + case Event.ADDCANVAS -> { + doAddCanvas(event.getContent()); + return true; + } + default -> { + return false; + } + } + } + + private void doAddCanvas(JSONObject content) throws JSONException { + canvasWidth = content.getDouble("width"); + canvasHeight = content.getDouble("height"); + sendAllOtherUsers(new Event(Event.CNVS, content)); + } + + private void doDelLine(JSONObject content) { + Line line = Line.fromJSONArray(content.getJSONArray("line")); + this.user.getLines().remove(line); + + sendAllOtherUsers(new Event("DELLINE", line.toJSONObject())); + } + + private void doAddLine(JSONObject content) { + try { + System.out.println(Line.fromJSONArray(content.getJSONArray("line"))); + } catch (Exception e) { + System.out.println(e); + e.printStackTrace(); + } + Line line = Line.fromJSONArray(content.getJSONArray("line")); + this.user.getLines().add(line); + sendAllOtherUsers(new Event("LINE", line.toJSONObject())); + + } + + private void sendAllOtherUsers(Event event) { + System.out.println("current user: " + this.user.getUsername()); + for (DrawClientHandler client : clientList) { + System.out.println("calculating user: " + client.user.getUsername()); + if (client.user != this.user) { + System.out.println("found user: " + client.user.getUsername()); + sendEvent(client, event); + } + } + } + + private void sendEvent(DrawClientHandler client, Event event) { + String jsonEvent = event.toJSON(); + client.out.println(jsonEvent); + } + + private void doSendLines() { + out.println( + new Event("CNVS", new JSONObject() + .put("width", canvasWidth) + .put("height", canvasHeight)) + ); + + Vector lines = new Vector<>(); + for (DrawClientHandler client: clientList) { + for (Line line: client.user.getLines()) { + lines.add(line); + } + } + for (Line line: lines) { + out.println(new Event("LINE", line.toJSONObject())); + } + } + + private void doLogin(JSONObject content) { + this.user = new User(content.getString("username")); + } + + + public void close() { + try { + sock.close(); + removeClient(this); + } catch (IOException e) { + throw new RuntimeException(e); + } + + } + + + } +} diff --git a/graphical-app/src/main/java/fr/emiko/net/Event.java b/graphical-app/src/main/java/fr/emiko/net/Event.java new file mode 100644 index 0000000..519059d --- /dev/null +++ b/graphical-app/src/main/java/fr/emiko/net/Event.java @@ -0,0 +1,52 @@ +package fr.emiko.net; + +import org.json.JSONObject; + +public class Event { + public static final String AUTH = "AUTH"; + public static final String LSTLINE = "LSTLINE"; + public static final String ADDLINE = "ADDLINE"; + public static final String DELLINE = "DELLINE"; + public static final String LINE = "LINE"; + public static final String LINELST = "LINELST"; + public static final String ADDCANVAS = "ADDCANVAS"; + public static final String CNVS = "CNVS"; + + private String type; + private JSONObject content; + + public Event(String type, JSONObject content) { + this.type = type; + this.content = content; + } + + public String getType() { + return type; + } + + public JSONObject getContent() { + return content; + } + + public JSONObject toJSONObject() { + return new JSONObject() + .put("type", type) + .put("content", content); + } + + public String toJSON() { + return toJSONObject().toString(); + } + + public static Event fromJSON(String obj) { + JSONObject jobj = new JSONObject(obj); + String type = jobj.getString("type"); + JSONObject content = jobj.getJSONObject("content"); + return new Event(type, content); + } + + @Override + public String toString() { + return this.toJSON(); + } +} diff --git a/graphical-app/src/main/java/fr/emiko/net/User.java b/graphical-app/src/main/java/fr/emiko/net/User.java new file mode 100644 index 0000000..edf8f50 --- /dev/null +++ b/graphical-app/src/main/java/fr/emiko/net/User.java @@ -0,0 +1,32 @@ +package fr.emiko.net; + +import fr.emiko.graphicsElement.Line; + +import java.util.Vector; + +public class User { + private String username; + private String hashedPassword = ""; + private Vector lines = new Vector(); + private boolean connected; + + public User(String username) { + this.username = username; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Vector getLines() { + return lines; + } + + public void setLines(Vector lines) { + this.lines = lines; + } +} diff --git a/graphical-app/src/main/java/module-info.java b/graphical-app/src/main/java/module-info.java index 2311359..682ea3e 100644 --- a/graphical-app/src/main/java/module-info.java +++ b/graphical-app/src/main/java/module-info.java @@ -1,6 +1,10 @@ module fr.emiko.graphicalapp { requires javafx.controls; requires javafx.fxml; + requires java.desktop; + requires org.controlsfx.controls; + requires net.synedra.validatorfx; + requires org.json; opens fr.emiko.graphicalapp to javafx.fxml; diff --git a/graphical-app/src/main/resources/fr/emiko/graphicalapp/draw-view.fxml b/graphical-app/src/main/resources/fr/emiko/graphicalapp/draw-view.fxml new file mode 100644 index 0000000..19a9cb0 --- /dev/null +++ b/graphical-app/src/main/resources/fr/emiko/graphicalapp/draw-view.fxml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +