2
0
Эх сурвалжийг харах

Implement rudimentary editor

Thomas Dy 7 жил өмнө
parent
commit
1080bf4d8f
4 өөрчлөгдсөн 400 нэмэгдсэн , 5 устгасан
  1. 76 0
      dist/editor.css
  2. 51 0
      dist/editor.html
  3. 53 5
      src/audio.ts
  4. 220 0
      src/editor.ts

+ 76 - 0
dist/editor.css

@@ -0,0 +1,76 @@
+.scrubber {
+  display: grid;
+  grid-template-rows: 10px 20px;
+  background: lightgrey;
+}
+
+.bar {
+  grid-row: 1 / 2;
+}
+
+.bar-overlay {
+  background: red;
+  height: 100%;
+}
+
+.markers {
+  grid-row: 2 / 3;
+  position: relative;
+}
+
+.marker {
+  position: absolute;
+  box-sizing: border-box;
+  border-left: solid 2px black;
+  border-bottom: solid 5px black;
+  height: 100%;
+  width: 5px;
+}
+
+.text-areas {
+  display: grid;
+  grid-template-columns: 33% 33% auto;
+  grid-template-rows: 20px auto;
+  grid-gap: 2px;
+}
+
+#intervals-label,
+#kana-label,
+#kanji-label {
+  grid-row: 1 / 2;
+}
+
+#intervals, #kana, #kanji {
+  grid-row: 2 / 3;
+}
+
+#intervals,
+#intervals-label {
+  grid-column: 1 / 2;
+}
+
+#kana,
+#kana-label {
+  grid-column: 2 / 3;
+}
+
+#kanji,
+#kanji-label {
+  grid-columN: 3 / 4;
+}
+
+#intervals input {
+  width: 100px;
+}
+
+#kanji, #kana {
+  border: solid 1px lightgrey;
+  min-height: 200px;
+  white-space: pre;
+  overflow-x: auto;
+}
+
+#json {
+  width: 100%;
+  height: 200px;
+}

+ 51 - 0
dist/editor.html

@@ -0,0 +1,51 @@
+<html>
+  <head>
+    <title>Typing Freaks Editor</title>
+    <link rel="stylesheet" href="editor.css" />
+  </head>
+  <body>
+    <div id="#container">
+      <input id="audio" type="file" />
+
+      <button id="play">Play</button>
+      <button id="pause">Pause</button>
+      <button id="insert-marker">Insert Marker</button>
+      <div class="scrubber">
+        <div class="bar">
+          <div class="bar-overlay"></div>
+        </div>
+        <div class="markers">
+        </div>
+      </div>
+
+      <div class="text-areas">
+        <span id="intervals-label">Intervals</span>
+        <span id="kanji-label">Kanji</span>
+        <span id="kana-label">Kana</span>
+        <ul id="intervals"></ul>
+        <div id="kanji" contentEditable></div>
+        <div id="kana" contentEditable></div>
+      </div>
+
+      <div>
+        <textarea id="json" placeholder="JSON"></textarea>
+      </div>
+      <div>
+        <button id="import">Import</button>
+        <button id="export">Export</button>
+      </div>
+    </div>
+    <template id="interval-template">
+      <li>
+        <input class="interval" type="number" step="0.1">
+        <button class="play-section">Play</button>
+        <button class="remove-section">Remove</button>
+      </li>
+    </template>
+    <script type="text/javascript" src="bundle.js"></script>
+    <script type="text/javascript">
+      let e = new editor.Editor();
+      e.start();
+    </script>
+  </body>
+</html>

+ 53 - 5
src/audio.ts

@@ -22,6 +22,17 @@ namespace audio {
         .then(audioBuffer => new Track(this, audioBuffer))
     }
 
+    loadTrackFromFile(file: File): Promise<Track> {
+      let promise = new Promise<ArrayBuffer>((resolve, reject) => {
+        let reader = new FileReader();
+        reader.onloadend = () => resolve(reader.result);
+        reader.readAsArrayBuffer(file);
+      });
+      return promise
+        .then(buffer => this.context.decodeAudioData(buffer))
+        .then(audioBuffer => new Track(this, audioBuffer))
+    }
+
     loadTrackWithProgress(url: string, listener: EventListener): Promise<Track> {
       let promise = new Promise<ArrayBuffer>((resolve, reject) => {
         let xhr = new XMLHttpRequest();
@@ -43,6 +54,7 @@ namespace audio {
     buffer: AudioBuffer;
     source: AudioBufferSourceNode | null;
     playStartTime: number;
+    resumeTime: number;
     hasStarted: boolean;
     isFinished: boolean;
 
@@ -50,6 +62,7 @@ namespace audio {
       this.manager = manager;
       this.buffer = buffer;
       this.playStartTime = 0;
+      this.resumeTime = 0;
       this.hasStarted = false;
       this.isFinished = false;
     }
@@ -58,25 +71,60 @@ namespace audio {
       this.source = this.manager.context.createBufferSource();
       this.source.buffer = this.buffer;
       this.source.connect(this.manager.output);
-      this.source.onended = () => {
-        this.isFinished = true;
-      }
+      this.playStartTime = this.manager.getTime();
       this.isFinished = false;
       this.hasStarted = true;
-      this.playStartTime = this.manager.getTime();
       this.source.start();
     }
 
+    start(duration: number = undefined): void {
+      this.source = this.manager.context.createBufferSource();
+      this.source.buffer = this.buffer;
+      this.source.connect(this.manager.output);
+      this.source.onended = (event) => {
+        if (this.source == event.target) {
+          this.isFinished = true;
+          if (duration > 0) {
+            this.resumeTime += duration;
+            if (this.resumeTime > this.getDuration()) {
+              this.resumeTime = 0;
+            }
+          }
+        }
+      }
+      this.isFinished = false;
+      this.hasStarted = true;
+      this.playStartTime = this.manager.getTime() - this.resumeTime;
+      this.source.start(0, this.resumeTime, duration);
+    }
+
+    pause(): void {
+      this.resumeTime = this.manager.getTime() - this.playStartTime;
+      this.isFinished = true;
+      if (this.source) {
+        this.source.stop();
+      }
+    }
+
     stop(): void {
+      this.resumeTime = 0;
       this.isFinished = true;
       if (this.source) {
         this.source.stop();
       }
     }
 
+    isPlaying(): boolean {
+      return this.hasStarted && !this.isFinished;
+    }
+
     getTime(): number {
       if (this.isFinished) {
-        return this.getDuration();
+        if (this.resumeTime > 0) {
+          return this.resumeTime;
+        } else {
+          return this.getDuration();
+        }
       } else if (!this.hasStarted) {
         return 0;
       } else {

+ 220 - 0
src/editor.ts

@@ -0,0 +1,220 @@
+/// <reference path="util.ts" />
+/// <reference path="level.ts" />
+/// <reference path="audio.ts" />
+
+namespace editor {
+  export class Editor {
+    audioManager: audio.AudioManager;
+    audioElement: HTMLInputElement;
+    barElement: HTMLElement;
+    markerListElement: HTMLElement;
+    intervalListElement: HTMLElement;
+    kanaElement: HTMLElement;
+    kanjiElement: HTMLElement;
+    jsonElement: HTMLInputElement;
+    track: audio.Track | null = null;
+    markers: Marker[] = [];
+    rafId: number;
+
+    constructor() {
+      this.audioManager = new audio.AudioManager();
+      this.audioElement = document.querySelector('#audio');
+      this.audioElement.addEventListener('change', event => {
+        this.loadAudio();
+      });
+      this.barElement = document.querySelector('.bar-overlay');
+      this.markerListElement = document.querySelector('.markers');
+      this.intervalListElement = document.querySelector('#intervals');
+      this.kanaElement = document.querySelector('#kana');
+      this.kanjiElement = document.querySelector('#kanji');
+      this.jsonElement = document.querySelector('#json');
+
+      this.markerListElement.addEventListener('click', (event: MouseEvent) => this.markersClick(event));
+      document.querySelector('#play').addEventListener('click', () => this.play());
+      document.querySelector('#pause').addEventListener('click', () => this.pause());
+      document.querySelector('#insert-marker').addEventListener('click', () => this.insertMarker());
+      document.querySelector('.bar').addEventListener('click', (event: MouseEvent) => this.scrubberClick(event));
+      document.querySelector('#import').addEventListener('click', () => this.import());
+      document.querySelector('#export').addEventListener('click', () => this.export());
+    }
+
+    loadAudio(): void {
+      let file = this.audioElement.files[0];
+      if (file != null) {
+        if (this.track != null) {
+          this.track.stop();
+        }
+        this.clearMarkers();
+        this.audioManager.loadTrackFromFile(file).then(t => this.track = t);
+      }
+    }
+
+    update(): void {
+      let percentage = this.track.getTime() / this.track.getDuration() * 100;
+      this.barElement.style.width = `${percentage}%`;
+      if (this.track.isPlaying()) {
+        this.rafId = requestAnimationFrame(() => this.update());
+      }
+    }
+
+    scrubberClick(event: MouseEvent): void {
+      let pos = event.clientX - this.markerListElement.offsetLeft;
+      let percentage = pos / this.markerListElement.clientWidth;
+      let targetTime = percentage * this.track.getDuration();
+      this.track.stop();
+      this.track.resumeTime = targetTime;
+      this.play();
+    }
+
+    markersClick(event: MouseEvent): void {
+      let pos = event.clientX - this.markerListElement.offsetLeft;
+      let percentage = pos / this.markerListElement.clientWidth;
+      let targetTime = percentage * this.track.getDuration();
+      this.insertMarker(targetTime);
+    }
+
+    insertMarker(time: number = undefined): void {
+      let marker = new Marker(
+        this.track.getDuration(),
+        (marker: Marker) => this.removeMarker(marker),
+        (marker: Marker) => this.playMarker(marker)
+      );
+      if (time !== undefined) {
+        marker.time = time;
+      } else {
+        marker.time = this.track.getTime();
+      }
+      let insertIndex = this.markers.findIndex(m => m.time > marker.time);
+      if (insertIndex >= 0) {
+        this.markers.splice(insertIndex, 0, marker);
+      } else {
+        this.markers.push(marker);
+      }
+      this.markerListElement.appendChild(marker.markerElement);
+      if (insertIndex >= 0) {
+        this.intervalListElement.insertBefore(marker.liElement, this.markers[insertIndex+1].liElement);
+      } else {
+        this.intervalListElement.appendChild(marker.liElement);
+      }
+    }
+
+    play(duration: number = undefined): void {
+      window.cancelAnimationFrame(this.rafId);
+      this.track.start(duration);
+      this.update();
+    }
+
+    pause(): void {
+      this.track.pause();
+    }
+
+    playMarker(marker: Marker): void {
+      let start = 0;
+      let index = this.markers.findIndex(m => m == marker);
+      if (index > 0) {
+        start = this.markers[index - 1].time;
+      }
+      let duration = marker.time - start;
+      this.track.stop();
+      this.track.resumeTime = start;
+      this.play(duration);
+    }
+
+    removeMarker(marker: Marker): void {
+      let index = this.markers.findIndex(m => m == marker);
+      this.markers.splice(index, 1);
+      this.markerListElement.removeChild(marker.markerElement);
+      this.intervalListElement.removeChild(marker.liElement);
+    }
+
+    clearMarkers(): void {
+      this.markers.forEach(m => {
+        this.markerListElement.removeChild(m.markerElement);
+        this.intervalListElement.removeChild(m.liElement);
+      });
+      this.markers = [];
+    }
+
+    import(): void {
+      this.clearMarkers();
+      let lines: level.Line[] = JSON.parse(this.jsonElement.value);
+      let kanji = '';
+      let kana = '';
+
+      lines.forEach(line => {
+        kanji += line.kanji + '<br>';
+        kana += line.kana + '<br>';
+        if (line.end != undefined) {
+          this.insertMarker(line.end);
+        }
+      });
+
+      this.kanjiElement.innerHTML = kanji;
+      this.kanaElement.innerHTML = kana;
+    }
+
+    export(): void {
+      let kanji = this.kanjiElement.innerHTML.split('<br>');
+      let kana = this.kanaElement.innerHTML.split('<br>');
+      kanji.pop();
+      kana.pop();
+      let length = Math.max(kanji.length, kana.length, this.markers.length);
+
+      let lines = [];
+      let lastStart = 0;
+      for (let i = 0; i < length; ++i) {
+        let data: level.Line = {
+          kanji: kanji[i] || '@',
+          kana: kana[i] || '@',
+        }
+        if (this.markers[i]) {
+          data.start = lastStart;
+          data.end = this.markers[i].time;
+          lastStart = data.end;
+        }
+        lines.push(data);
+      }
+
+      this.jsonElement.value = JSON.stringify(lines);
+    }
+
+    start(): void {
+      this.loadAudio();
+    }
+  }
+
+  class Marker {
+    markerElement: HTMLElement;
+    liElement: HTMLElement;
+    inputElement: HTMLInputElement;
+
+    constructor(
+      readonly duration: number,
+      readonly remove: (marker: Marker) => void,
+      readonly play: (marker: Marker) => void
+    ) {
+      this.markerElement = document.createElement('div');
+      this.markerElement.className = 'marker';
+
+      let fragment = util.loadTemplate('interval');
+      this.liElement = fragment.querySelector('*');
+      this.inputElement = fragment.querySelector('.interval');
+      this.inputElement.addEventListener('change', () => {
+        this.time = parseFloat(this.inputElement.value);
+      });
+
+      fragment.querySelector('.play-section').addEventListener('click', () => play(this));
+      fragment.querySelector('.remove-section').addEventListener('click', () => remove(this));
+    }
+
+    get time(): number {
+      return parseFloat(this.inputElement.value);
+    }
+
+    set time(t: number) {
+      this.inputElement.value = t.toFixed(1);
+      let percentage = t * 100 / this.duration;
+      this.markerElement.style.left = `${percentage}%`;
+    }
+  }
+}