Ver código fonte

Overhaul editor

Thomas Dy 3 anos atrás
pai
commit
904ce3c13f
10 arquivos alterados com 1372 adições e 498 exclusões
  1. 134 69
      src/editor.css
  2. 94 42
      src/editor.html
  3. 0 387
      src/editor.ts
  4. 237 0
      src/editor/index.ts
  5. 173 0
      src/editor/level.ts
  6. 184 0
      src/editor/levelSet.ts
  7. 412 0
      src/editor/lyrics.ts
  8. 37 0
      src/editor/util.ts
  9. 92 0
      src/editor/waveForm.ts
  10. 9 0
      src/util.ts

+ 134 - 69
src/editor.css

@@ -2,29 +2,129 @@ body {
   margin: 0;
 }
 
-#controls {
-  position: fixed;
-  width: 100%;
-  background-color: white;
+#container {
+  padding: 10px;
+}
+
+.hide {
+  display: none;
+}
+
+#config-screen,
+#level-screen {
+  display: none;
+}
+
+.loaded #open-screen {
+  display: none;
+}
+
+.loaded #config-screen {
+  display: block;
+}
+
+.editing #config-screen {
+  display: none;
+}
+
+.editing #level-screen {
   display: flex;
+  flex-direction: column;
+  gap: 5px;
 }
 
-#controls > div {
-  flex-grow: 1;
+#config-navigation {
+  display: inline-flex;
+  gap: 5px;
 }
 
-.waveform-container {
-  position: relative;
-  height: 60px;
+#levels {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
 }
 
-#waveform,
-#waveform-overlay {
-  position: absolute;
-  height: 100%;
+.level-set .header {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+}
+
+table.level-list {
+  border-collapse: collapse;
   width: 100%;
 }
 
+.level-list tbody tr:hover {
+  background: #eee;
+}
+
+.level-list th,
+.level-list td {
+  border: solid 1px black;
+  padding: 0px 5px;
+}
+
+.level-list input {
+  width: 80%;
+}
+
+.lyrics {
+  padding-bottom: 200px;
+  display: grid;
+  grid-template-columns: auto auto auto 1fr 1fr [end];
+  grid-gap: 2px;
+}
+
+.lyrics .time {
+  width: 100px;
+  height: 30px;
+  border-radius: 2px;
+  border-color: silver;
+  border-style: solid;
+  border-width: 1px;
+  padding: 0px 8px;
+}
+
+.lyrics .rest-of-song {
+  grid-column: 2 / end;
+  display: flex;
+  align-items: center;
+  margin-left: 20px;
+}
+
+.lyrics .time:invalid {
+  border-color: red;
+}
+
+.no-audio .lyrics {
+  grid-template-columns: auto 1fr 1fr;
+}
+
+.no-audio .lyrics .time,
+.no-audio .lyrics .play-section,
+.no-audio .lyrics .rest-of-song {
+  display: none;
+}
+
+#level-screen .playback {
+  display: flex;
+  position: fixed;
+  width: calc(100% - 20px);
+  bottom: 5px;
+  background: white;
+  gap: 5px;
+  box-shadow: 0px 0px 9px 0px gray;
+}
+
+.controls {
+  flex-grow: 1;
+  display: flex;
+  flex-direction: column;
+  gap: 5px;
+  margin: 5px 0px;
+}
+
 .scrubber {
   display: grid;
   grid-template-rows: 10px 20px;
@@ -40,10 +140,6 @@ body {
   height: 100%;
 }
 
-li.highlight {
-  background-color: yellow;
-}
-
 .markers {
   grid-row: 2 / 3;
   position: relative;
@@ -58,69 +154,38 @@ li.highlight {
   width: 5px;
 }
 
-.text-areas {
-  padding-top: 160px;
-  display: grid;
-  grid-template-columns: auto 1fr 1fr;
-  grid-template-rows: 20px auto;
-  grid-gap: 2px;
-}
-
-#url {
-  width: 300px;
-}
-
-#intervals-label,
-#kana-label,
-#kanji-label {
-  grid-row: 1 / 2;
-}
-
-#intervals,
-#kana,
-#kanji {
-  grid-row: 2 / 3;
-}
-
-#intervals,
-#intervals-label {
-  min-width: 250px;
-  grid-column: 1 / 2;
-}
-
-#kana,
-#kana-label {
-  grid-column: 2 / 3;
-}
-
-#kanji,
-#kanji-label {
-  grid-column: 3 / 4;
+.waveform-container {
+  position: relative;
+  height: 60px;
+  background: lightgrey;
 }
 
-#intervals input {
-  width: 100px;
+#waveform,
+#waveform-overlay {
+  position: absolute;
+  height: 100%;
+  width: 100%;
 }
 
-#kanji,
-#kana {
-  border: solid 1px lightgrey;
-  min-height: 200px;
-  white-space: pre;
-  overflow-x: auto;
-  font-size: 16px;
+#level-screen.no-waveform .waveform-container {
+  display: none;
 }
 
-#json {
-  width: 100%;
-  height: 200px;
+#display {
+  flex-grow: 1;
+  font-size: 150%;
+  min-height: 2em;
 }
 
-div#youtube {
+#youtube {
   flex-grow: 0;
 }
 
-iframe#youtube {
+#youtube iframe {
   width: 240px;
   height: 135px;
 }
+
+#level-screen.no-audio .playback {
+  display: none;
+}

+ 94 - 42
src/editor.html

@@ -6,53 +6,105 @@
   </head>
   <body>
     <div id="container">
-      <div id="controls">
-        <div>
-          <input id="url" type="text" />
-          <button id="load">Load from url (Youtube/etc)</button>
-          <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 id="open-screen">
+        <button id="open-new">Create New</button>
+        or load an existing file
+        <input id="open-file" type="file" accept="application/json" />
+      </div>
+      <div id="config-screen">
+        <span id="config-navigation"></span>
+        <button id="config-add">Add Level Set</button>
+        <button id="config-download">Download</button>
+        <button id="config-close">Close</button>
+        <div id="config-levels"></div>
+      </div>
+      <div id="level-screen">
+        <div class="playback">
+          <div id="youtube"></div>
+          <div class="controls">
+            <div id="display"></div>
+            <div class="waveform-container">
+              <canvas id="waveform"></canvas>
+              <canvas id="waveform-overlay"></canvas>
+            </div>
+            <div class="scrubber">
+              <div class="bar">
+                <div class="bar-overlay"></div>
+              </div>
+              <div class="markers"></div>
+            </div>
+            <div>
+              <button id="play">Play</button>
+              <button id="pause">Pause</button>
+              <button id="insert-marker">Insert Marker</button>
             </div>
-            <div class="markers"></div>
-          </div>
-          <div class="waveform-container">
-            <canvas id="waveform"></canvas>
-            <canvas id="waveform-overlay"></canvas>
           </div>
-          <div id="display"></div>
         </div>
-        <div id="youtube"></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>
-        <textarea id="kanji"></textarea>
-        <textarea id="kana"></textarea>
-      </div>
-
-      <div>
-        <textarea id="json" placeholder="JSON"></textarea>
-      </div>
-      <div>
-        <button id="import">Import</button>
-        <button id="export">Export</button>
+        <div class="header">
+          <span id="level-title"></span>
+          <button id="level-save">Save</button>
+          <button id="level-close">Close</button>
+        </div>
+        <div class="lyrics"></div>
       </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 id="line-template">
+      <input
+        class="time"
+        type="number"
+        step="0.05"
+        required
+        autocomplete="off"
+      />
+      <button class="play-section">Play</button>
+      <button class="remove-section">Remove</button>
+      <input class="kana" autocomplete="off" />
+      <input class="kanji" autocomplete="off" />
+    </template>
+    <template id="level-set-template">
+      <div class="level-set">
+        <div class="header">
+          <div class="name"></div>
+          <button class="add-level">Add Level</button>
+          <button class="move-up">^</button>
+          <button class="move-down">v</button>
+          <button class="remove">Remove</button>
+        </div>
+        <table class="level-list">
+          <thead>
+            <tr>
+              <th>Title</th>
+              <th>Creator</th>
+              <th>Genre</th>
+              <th>Difficulty</th>
+              <th>Audio</th>
+              <th>Link</th>
+              <th>Lyrics</th>
+              <th>Actions</th>
+            </tr>
+          </thead>
+          <tbody> </tbody>
+        </table>
+      </div>
+    </template>
+    <template id="level-list-item-template">
+      <tr>
+        <td class="name"></td>
+        <td class="creator"></td>
+        <td class="genre"></td>
+        <td class="difficulty"></td>
+        <td class="audio"></td>
+        <td class="link"></td>
+        <td>
+          <button class="edit-lyrics">Edit Lyrics</button>
+        </td>
+        <td>
+          <button class="move-up">^</button>
+          <button class="move-down">v</button>
+          <button class="remove-level">Remove</button>
+        </td>
+      </tr>
     </template>
-    <script type="module" src="editor.js"></script>
+    <script type="module" src="editor/index.js"></script>
   </body>
 </html>

+ 0 - 387
src/editor.ts

@@ -1,387 +0,0 @@
-import * as audio from './audio';
-import * as level from './level';
-import * as util from './util';
-import * as youtube from './youtube';
-
-export class Editor {
-  audioManager: audio.AudioManager;
-  urlElement: HTMLInputElement;
-  loadElement: HTMLButtonElement;
-  audioElement: HTMLInputElement;
-  barElement: HTMLElement;
-  markerListElement: HTMLElement;
-  intervalListElement: HTMLElement;
-  kanaElement: HTMLTextAreaElement;
-  kanjiElement: HTMLTextAreaElement;
-  displayElement: HTMLElement;
-  jsonElement: HTMLInputElement;
-  waveFormContainer: HTMLDivElement;
-  track: audio.Track | null = null;
-  markers: Marker[] = [];
-  waveForm: WaveForm;
-  currentMarker: Marker | null = null;
-
-  constructor() {
-    this.audioManager = new audio.AudioManager();
-    this.urlElement = util.getElement(document, '#url');
-    this.loadElement = util.getElement(document, '#load');
-    this.loadElement.addEventListener('click', (event) => {
-      this.loadAudio();
-    });
-    this.audioElement = util.getElement(document, '#audio');
-    this.audioElement.addEventListener('change', (event) => {
-      this.urlElement.value = '';
-      this.loadAudio();
-    });
-    this.barElement = util.getElement(document, '.bar-overlay');
-    this.markerListElement = util.getElement(document, '.markers');
-    this.intervalListElement = util.getElement(document, '#intervals');
-    this.kanaElement = util.getElement(document, '#kana');
-    this.kanjiElement = util.getElement(document, '#kanji');
-    this.displayElement = util.getElement(document, '#display');
-    this.jsonElement = util.getElement(document, '#json');
-    this.waveFormContainer = util.getElement(document, '.waveform-container');
-    this.waveForm = new WaveForm(
-      util.getElement(document, '#waveform'),
-      util.getElement(document, '#waveform-overlay'),
-      (time: number) => this.play(time)
-    );
-
-    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<HTMLElement>('.bar')!
-      .addEventListener('click', (event: MouseEvent) =>
-        this.scrubberClick(event)
-      );
-    document
-      .querySelector('#import')!
-      .addEventListener('click', () => this.import());
-    document
-      .querySelector('#export')!
-      .addEventListener('click', () => this.export());
-
-    this.update();
-  }
-
-  loadAudio(): void {
-    const url = this.urlElement.value;
-    if (url != '') {
-      const videoId = youtube.getVideoId(url);
-      if (videoId !== null) {
-        const element = util.getElement(document, '#youtube');
-        this.audioManager
-          .loadTrackFromYoutube(videoId, element, () => {})
-          .then((t) => {
-            this.track = t;
-            this.waveForm.clear();
-            this.waveFormContainer.style.display = 'none';
-          });
-        return;
-      }
-    }
-
-    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;
-        this.waveForm.setTrack(t);
-        this.waveFormContainer.style.display = 'block';
-      });
-    }
-  }
-
-  update(): void {
-    if (this.track != null) {
-      let percentage = (this.track.getTime() / this.track.getDuration()) * 100;
-      this.barElement.style.width = `${percentage}%`;
-      if (this.track instanceof audio.FileTrack) {
-        this.waveForm.update(this.markers);
-      }
-      if (this.currentMarker) {
-        this.currentMarker.liElement.className = '';
-      }
-      let index = this.markers.findIndex((m) => m.time > this.track!.getTime());
-      if (index < 0) index = 0;
-      this.currentMarker = this.markers[index - 1];
-      if (this.currentMarker) {
-        this.currentMarker.liElement.className = 'highlight';
-      }
-      let text = this.kanjiElement.value.split('\n')[index] || '';
-      this.displayElement.textContent = text;
-    }
-    requestAnimationFrame(() => this.update());
-  }
-
-  scrubberClick(event: MouseEvent): void {
-    let pos = event.clientX - 10;
-    console.log(pos);
-    let percentage = pos / this.markerListElement.clientWidth;
-    let targetTime = percentage * this.track!.getDuration();
-    this.play(targetTime);
-  }
-
-  markersClick(event: MouseEvent): void {
-    let pos = event.clientX - 10;
-    let percentage = pos / this.markerListElement.clientWidth;
-    let targetTime = percentage * this.track!.getDuration();
-    this.insertMarker(targetTime);
-  }
-
-  insertMarker(time?: number): 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(start?: number, duration?: number): void {
-    this.track!.pause();
-    this.track!.start(start, duration);
-  }
-
-  pause(): void {
-    this.track!.pause();
-  }
-
-  playMarker(marker: Marker): void {
-    let start = marker.time;
-    let end = this.track!.getDuration();
-    let index = this.markers.findIndex((m) => m == marker);
-    if (index < this.markers.length - 1) {
-      end = this.markers[index + 1].time;
-    }
-    let duration = end - start;
-    this.play(start, duration);
-
-    this.highlightLine(this.kanjiElement, index + 1);
-  }
-
-  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 = [];
-  }
-
-  highlightLine(element: HTMLTextAreaElement, line: number) {
-    let text = element.value;
-    let index = 0;
-    for (let i = 0; i < line; ++i) {
-      index = text.indexOf('\n', index + 1);
-    }
-    let endIndex = text.indexOf('\n', index + 1);
-    element.focus();
-    element.setSelectionRange(index, endIndex);
-  }
-
-  import(): void {
-    this.clearMarkers();
-    let lines: level.Line[] = JSON.parse(this.jsonElement.value);
-    let kanji = '';
-    let kana = '';
-
-    lines.forEach((line) => {
-      kanji += line.kanji + '\n';
-      kana += line.kana + '\n';
-      if (line.end != undefined) {
-        this.insertMarker(line.end);
-      }
-    });
-
-    this.kanjiElement.value = kanji;
-    this.kanaElement.value = kana;
-  }
-
-  export(): void {
-    let kanji = this.kanjiElement.value.split('\n');
-    let kana = this.kanaElement.value.split('\n');
-    let length = Math.max(kanji.length, kana.length, this.markers.length - 1);
-
-    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(document, 'interval');
-    this.liElement = util.getElement(fragment, '*');
-    this.inputElement = util.getElement(fragment, '.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}%`;
-  }
-}
-
-class WaveForm {
-  ctx: CanvasRenderingContext2D;
-  overlayCtx: CanvasRenderingContext2D;
-  track: audio.FileTrack | null = null;
-  data: Float32Array | null = null;
-  stride: number = 0;
-  currentSection: number = -1;
-
-  constructor(
-    readonly canvas: HTMLCanvasElement,
-    readonly overlay: HTMLCanvasElement,
-    readonly setTime: (time: number) => void
-  ) {
-    canvas.height = canvas.clientHeight;
-    canvas.width = canvas.clientWidth;
-    overlay.height = overlay.clientHeight;
-    overlay.width = overlay.clientWidth;
-    this.ctx = canvas.getContext('2d')!;
-    this.overlayCtx = overlay.getContext('2d')!;
-
-    this.overlayCtx.fillStyle = 'rgba(255, 0, 0, 0.5)';
-
-    this.overlay.addEventListener('click', (event: MouseEvent) => {
-      let pos = event.clientX - this.overlay.offsetLeft;
-      let percentage = pos / this.overlay.width;
-      let time = this.currentSection * 5 + percentage * 5;
-      this.setTime(time);
-    });
-  }
-
-  clear(): void {
-    this.track = null;
-  }
-
-  setTrack(track: audio.FileTrack): void {
-    this.track = track;
-    this.stride = Math.floor(
-      (this.track.buffer.sampleRate / this.canvas.width) * 5
-    );
-    this.currentSection = -1;
-  }
-
-  timeToX(time: number): number {
-    return ((time - this.currentSection * 5) / 5) * this.canvas.width;
-  }
-
-  update(markers: Marker[]): void {
-    let section = Math.floor(this.track!.getTime() / 5);
-
-    let height = this.canvas.height;
-    if (this.currentSection != section) {
-      this.data = this.track!.buffer.getChannelData(0);
-      this.ctx.clearRect(0, 0, this.canvas.width, height);
-      this.ctx.beginPath();
-      this.ctx.moveTo(0, height / 2);
-      let offset = section * this.canvas.width * this.stride;
-      for (let i = 0; i < this.canvas.width; ++i) {
-        let index = offset + i * this.stride;
-        let value = height / 2 + (height / 2) * this.data[index];
-        this.ctx.lineTo(i, value);
-      }
-      this.ctx.stroke();
-      this.currentSection = section;
-    }
-
-    let marker = this.timeToX(this.track!.getTime());
-    this.overlayCtx.clearRect(0, 0, this.canvas.width, height);
-    this.overlayCtx.fillRect(0, 0, marker, height);
-    markers.forEach((m) => {
-      if (m.time > section * 5 && m.time <= section * 5 + 5) {
-        let x = this.timeToX(m.time);
-        this.overlayCtx.beginPath();
-        this.overlayCtx.moveTo(x, 0);
-        this.overlayCtx.lineTo(x, height);
-        this.overlayCtx.stroke();
-      }
-    });
-  }
-}
-
-let e = new Editor();
-e.start();

+ 237 - 0
src/editor/index.ts

@@ -0,0 +1,237 @@
+import * as audio from '../audio';
+import * as level from '../level';
+import * as util from '../util';
+import * as youtube from '../youtube';
+import { LevelEditor } from './level';
+import { LevelSetEditor } from './levelSet';
+
+class ConfigEditor {
+  containerElement: HTMLDivElement;
+  openScreen: HTMLDivElement;
+  configScreen: HTMLDivElement;
+
+  openFileElement: HTMLInputElement;
+
+  navigationElement: HTMLElement;
+  levelsElement: HTMLDivElement;
+
+  levelEditor: LevelEditor;
+  audioManager: audio.AudioManager;
+
+  config: level.Config | null;
+  currentLevel: level.Level | null;
+
+  constructor() {
+    this.containerElement = util.getElement(document, '#container');
+    this.openScreen = util.getElement(document, '#open-screen');
+
+    this.openFileElement = util.getElement(this.openScreen, '#open-file');
+    this.openFileElement.addEventListener('change', () => {
+      this.load();
+    });
+    util.getElement(document, '#open-new').addEventListener('click', () => {
+      this.create();
+    });
+
+    this.configScreen = util.getElement(document, '#config-screen');
+    this.navigationElement = util.getElement(document, '#config-navigation');
+    this.levelsElement = util.getElement(document, '#config-levels');
+
+    util.getElement(document, '#config-add').addEventListener('click', () => {
+      this.addLevelSet();
+    });
+    util
+      .getElement(document, '#config-download')
+      .addEventListener('click', () => {
+        this.download();
+      });
+    util.getElement(document, '#config-close').addEventListener('click', () => {
+      this.close();
+    });
+
+    this.levelEditor = new LevelEditor(
+      () => {
+        this.containerElement.classList.remove('editing');
+        this.currentLevel = null;
+      },
+      (lines) => {
+        this.currentLevel!.lines = lines;
+        this.persistConfig();
+      }
+    );
+    this.audioManager = new audio.AudioManager();
+
+    this.config = null;
+    this.currentLevel = null;
+
+    const storedConfig = localStorage.getItem('LEVELS_JSON');
+    this.updateConfig(storedConfig === null ? null : JSON.parse(storedConfig));
+  }
+
+  updateConfig(config: level.Config | null) {
+    this.config = config;
+    this.containerElement.classList.toggle('loaded', config !== null);
+    this.persistConfig();
+    this.render();
+  }
+
+  persistConfig() {
+    if (this.config !== null) {
+      localStorage.setItem('LEVELS_JSON', JSON.stringify(this.config));
+    } else {
+      localStorage.removeItem('LEVELS_JSON');
+    }
+  }
+
+  create() {
+    this.updateConfig({
+      background: 'royalblue',
+      selectMusic: null,
+      baseColor: 'white',
+      highlightColor: 'cyan',
+      contrastColor: 'black',
+      selectSound: 'select.wav',
+      decideSound: 'decide.wav',
+      levelSets: [],
+    });
+  }
+
+  load() {
+    const file = this.openFileElement.files?.[0];
+    if (file !== undefined) {
+      file.arrayBuffer().then((buffer) => {
+        const decoder = new TextDecoder();
+        this.updateConfig(JSON.parse(decoder.decode(buffer)));
+      });
+    }
+  }
+
+  download() {
+    const a = document.createElement('a');
+    const url = URL.createObjectURL(new Blob([JSON.stringify(this.config)]));
+    a.href = url;
+    a.download = 'levels.json';
+    a.click();
+
+    URL.revokeObjectURL(url);
+  }
+
+  close() {
+    if (confirm('Are you sure you want to close?')) {
+      this.updateConfig(null);
+    }
+  }
+
+  render() {
+    if (this.config === null) {
+      return;
+    }
+    this.levelsElement.textContent = '';
+    this.navigationElement.textContent = '';
+
+    this.config.levelSets.forEach((levelSet, index) => {
+      const a = document.createElement('a');
+      a.href = `#level-set-${index}`;
+      a.textContent = levelSet.name;
+      this.navigationElement.appendChild(a);
+
+      new LevelSetEditor(
+        this.levelsElement,
+        index,
+        levelSet,
+        () => {
+          this.persistConfig();
+          this.render();
+        },
+        (level) => this.edit(level),
+        (index, direction) => this.moveLevelSet(index, direction),
+        (index) => this.removeLevelSet(index)
+      );
+    });
+  }
+
+  async edit(level: level.Level) {
+    const track = level.audio ? await this.loadAudio(level.audio) : null;
+    this.currentLevel = level;
+    this.containerElement.classList.add('editing');
+    this.levelEditor.load(level.name, level.lines, track);
+  }
+
+  async loadAudio(url: string): Promise<audio.Track> {
+    const youtubeContainer = util.getElement(document, '#youtube');
+    youtubeContainer.textContent = '';
+
+    const videoId = youtube.getVideoId(url);
+    if (videoId !== null) {
+      const element = document.createElement('div');
+      youtubeContainer.appendChild(element);
+      return await this.audioManager.loadTrackFromYoutube(
+        videoId,
+        element,
+        () => {}
+      );
+    }
+
+    const fileLoader = document.createElement('input');
+    fileLoader.type = 'file';
+    fileLoader.accept = 'audio/*';
+    return await new Promise((resolve, reject) => {
+      fileLoader.addEventListener('change', () => {
+        const file = fileLoader.files![0];
+        if (file !== null) {
+          resolve(this.audioManager.loadTrackFromFile(file));
+        } else {
+          reject('Cancelled');
+        }
+      });
+      fileLoader.click();
+    });
+  }
+
+  addLevelSet() {
+    if (this.config !== null) {
+      this.config.levelSets.push({
+        name: 'New Level Set',
+        levels: [],
+      });
+      this.persistConfig();
+      this.render();
+    }
+  }
+
+  moveLevelSet(index: number, direction: number): void {
+    if (this.config === null) {
+      return;
+    }
+    const target = index + direction;
+    if (target < 0) {
+      return;
+    }
+    if (target >= this.config.levelSets.length) {
+      return;
+    }
+
+    const level = this.config.levelSets[index];
+    this.config.levelSets.splice(index, 1);
+    this.config.levelSets.splice(target, 0, level);
+    this.persistConfig();
+    this.render();
+  }
+
+  removeLevelSet(index: number): void {
+    if (this.config === null) {
+      return;
+    }
+    const level = this.config.levelSets[index];
+    if (confirm(`Are you sure you want to remove ${level.name}?`)) {
+      this.config.levelSets.splice(index, 1);
+      this.persistConfig();
+      this.render();
+    }
+  }
+}
+
+const editor = new ConfigEditor();
+
+// @ts-ignore
+window.editor = editor;

+ 173 - 0
src/editor/level.ts

@@ -0,0 +1,173 @@
+import * as audio from '../audio';
+import * as level from '../level';
+import * as util from '../util';
+import { LyricsEditor } from './lyrics';
+import { WaveForm } from './waveForm';
+
+export class LevelEditor {
+  containerElement: HTMLElement;
+  levelTitleElement: HTMLElement;
+  levelScreenElement: HTMLElement;
+  barElement: HTMLElement;
+  markersListElement: HTMLElement;
+  displayElement: HTMLElement;
+  lyricsEditor: LyricsEditor;
+
+  track: audio.Track | null;
+  waveForm: WaveForm | null;
+
+  constructor(close: () => void, save: (lines: level.Line[]) => void) {
+    this.containerElement = util.getElement(document, '#container');
+    this.levelTitleElement = util.getElement(document, '#level-title');
+    this.levelScreenElement = util.getElement(document, '#level-screen');
+    this.barElement = util.getElement(document, '.bar-overlay');
+    this.markersListElement = util.getElement(document, '.markers');
+    this.displayElement = util.getElement(document, '#display');
+    this.lyricsEditor = new LyricsEditor((time, duration) => {
+      this.play(time, duration);
+    });
+
+    this.track = null;
+    this.waveForm = null;
+
+    util
+      .getElement(this.levelScreenElement, '#level-close')
+      .addEventListener('click', () => {
+        if (confirm('Are you sure you want to close?')) {
+          this.stop();
+          close();
+        }
+      });
+    util
+      .getElement(this.levelScreenElement, '#level-save')
+      .addEventListener('click', () => {
+        save(this.lyricsEditor.toLines());
+      });
+    util
+      .getElement(this.levelScreenElement, '#play')
+      .addEventListener('click', () => this.play());
+    util
+      .getElement(this.levelScreenElement, '#pause')
+      .addEventListener('click', () => this.pause());
+    util
+      .getElement(this.levelScreenElement, '#insert-marker')
+      .addEventListener('click', () => this.insertMarker());
+    util
+      .getElement(this.levelScreenElement, '.bar')
+      .addEventListener('click', (event: MouseEvent) =>
+        this.scrubberClick(event)
+      );
+  }
+
+  load(title: string, lines: level.Line[], track: audio.Track | null) {
+    this.levelTitleElement.textContent = title;
+    this.levelScreenElement.classList.toggle('no-audio', track === null);
+    this.levelScreenElement.classList.toggle(
+      'no-waveform',
+      track === null || !(track instanceof audio.FileTrack)
+    );
+    this.markersListElement.textContent = '';
+    this.lyricsEditor.loadLines(lines);
+    this.track = track;
+    if (track !== null && track instanceof audio.FileTrack) {
+      this.waveForm = new WaveForm(
+        util.getElement(document, '#waveform'),
+        util.getElement(document, '#waveform-overlay'),
+        (time: number) => this.play(time)
+      );
+      this.waveForm.setTrack(track);
+    } else {
+      this.waveForm = null;
+    }
+    this.update();
+  }
+
+  update(): void {
+    if (this.track !== null) {
+      const trackTime = this.track.getTime();
+      const duration = this.track.getDuration();
+      let percentage = (trackTime / duration) * 100;
+      this.barElement.style.width = `${percentage}%`;
+
+      const markers = this.markersListElement.children;
+      const times = [];
+      let currentDisplay: string | null = null;
+      let i = 0;
+
+      for (const line of this.lyricsEditor) {
+        const time = line.time;
+        if (Number.isNaN(time)) {
+          continue;
+        }
+        times.push(time);
+        line.timeInput.style.background = '';
+
+        let marker: HTMLElement | undefined = markers[i++] as HTMLElement;
+        if (marker === undefined) {
+          marker = document.createElement('div');
+          marker.className = 'marker';
+          this.markersListElement.appendChild(marker);
+        }
+        const percentage = (time * 100) / duration;
+        marker.style.left = `${percentage}%`;
+
+        if (currentDisplay === null && trackTime < time) {
+          const currentLine = line.previousLine;
+          if (currentLine) {
+            currentLine.timeInput.style.background = 'yellow';
+            currentDisplay = line.previousLine!.kanjiInput.value;
+          } else {
+            currentDisplay = '';
+          }
+        }
+      }
+
+      for (; i < markers.length; ++i) {
+        markers[i].remove();
+      }
+
+      if (this.waveForm !== null) {
+        this.waveForm.update(times);
+      }
+
+      this.displayElement.textContent = currentDisplay;
+      requestAnimationFrame(() => this.update());
+    }
+  }
+
+  play(start?: number, duration?: number): void {
+    this.track?.pause();
+    this.track?.start(start, duration);
+  }
+
+  pause(): void {
+    this.track?.pause();
+  }
+
+  insertMarker(time?: number): void {
+    const insertTime = time ?? this.track!.getTime();
+    this.lyricsEditor.addInterval(insertTime);
+  }
+
+  scrubberClick(event: MouseEvent): void {
+    let pos = event.clientX - this.barElement.getBoundingClientRect().left;
+    let percentage = pos / this.markersListElement.clientWidth;
+    let targetTime = percentage * this.track!.getDuration();
+    this.play(targetTime);
+  }
+
+  markersClick(event: MouseEvent): void {
+    let pos = event.clientX - this.barElement.getBoundingClientRect().left;
+    let percentage = pos / this.markersListElement.clientWidth;
+    let targetTime = percentage * this.track!.getDuration();
+    this.insertMarker(targetTime);
+  }
+
+  stop() {
+    if (this.track !== null) {
+      this.track.stop();
+    }
+    this.track = null;
+    this.lyricsEditor.clear();
+  }
+}

+ 184 - 0
src/editor/levelSet.ts

@@ -0,0 +1,184 @@
+import * as level from '../level';
+import * as util from '../util';
+import { makePropertyEditor } from './util';
+
+export class LevelSetEditor {
+  constructor(
+    containerElement: HTMLElement,
+    index: number,
+    readonly levelSet: level.LevelSet,
+    readonly save: () => void,
+    editLevel: (level: level.Level) => void,
+    moveLevelSet: (index: number, direction: number) => void,
+    removeLevelSet: (index: number) => void
+  ) {
+    const fragment = util.loadTemplate(document, 'level-set');
+    util.getElement(fragment, '.level-set').id = `level-set-${index}`;
+    makePropertyEditor(
+      util.getElement(fragment, '.name'),
+      levelSet.name,
+      (value) => {
+        levelSet.name = value;
+        this.save();
+      },
+      (value) => util.createElement('h2', value)
+    );
+    util
+      .getElement(fragment, '.header .add-level')
+      .addEventListener('click', () => {
+        this.addLevel();
+      });
+    util
+      .getElement(fragment, '.header .move-up')
+      .addEventListener('click', () => {
+        moveLevelSet(index, -1);
+      });
+    util
+      .getElement(fragment, '.header .move-down')
+      .addEventListener('click', () => {
+        moveLevelSet(index, 1);
+      });
+    util
+      .getElement(fragment, '.header .remove')
+      .addEventListener('click', () => {
+        removeLevelSet(index);
+      });
+
+    const levelList = util.getElement(fragment, '.level-list tbody');
+
+    levelSet.levels.forEach((level, index) => {
+      const fragment = util.loadTemplate(document, 'level-list-item');
+      this.makeLevelPropertyEditor(
+        util.getElement(fragment, '.name'),
+        level,
+        'name'
+      );
+      this.makeLevelPropertyEditor(
+        util.getElement(fragment, '.creator'),
+        level,
+        'creator'
+      );
+      this.makeLevelPropertyEditor(
+        util.getElement(fragment, '.genre'),
+        level,
+        'genre'
+      );
+      this.makeLevelPropertyEditor(
+        util.getElement(fragment, '.difficulty'),
+        level,
+        'difficulty'
+      );
+      this.makeLevelPropertyEditor(
+        util.getElement(fragment, '.audio'),
+        level,
+        'audio',
+        (value) => {
+          if (value === null) {
+            const elem = document.createElement('i');
+            elem.textContent = 'None';
+            return elem;
+          } else {
+            const a = document.createElement('a');
+            a.href = value ?? '';
+            a.textContent = value;
+            a.target = '_blank';
+            return a;
+          }
+        }
+      );
+      this.makeLevelPropertyEditor(
+        util.getElement(fragment, '.link'),
+        level,
+        'songLink',
+        (value) => {
+          const a = document.createElement('a');
+          a.href = value ?? '';
+          a.textContent = value;
+          a.target = '_blank';
+          return a;
+        }
+      );
+      util.getElement(fragment, '.move-up').addEventListener('click', () => {
+        this.moveLevel(index, -1);
+      });
+      util.getElement(fragment, '.move-down').addEventListener('click', () => {
+        this.moveLevel(index, 1);
+      });
+      util
+        .getElement(fragment, '.remove-level')
+        .addEventListener('click', () => {
+          this.removeLevel(index);
+        });
+      util
+        .getElement(fragment, '.edit-lyrics')
+        .addEventListener('click', () => {
+          editLevel(level);
+        });
+      levelList.appendChild(fragment);
+    });
+    containerElement.appendChild(fragment);
+  }
+
+  makeLevelPropertyEditor(
+    container: HTMLElement,
+    level: level.Level,
+    property:
+      | 'name'
+      | 'creator'
+      | 'genre'
+      | 'difficulty'
+      | 'audio'
+      | 'songLink',
+    render?: (value: string | null) => HTMLElement
+  ) {
+    makePropertyEditor(
+      container,
+      level[property] ?? null,
+      (value) => {
+        if (property === 'audio' && value === '') {
+          level[property] = null;
+        } else {
+          level[property] = value;
+        }
+        this.save();
+      },
+      render
+    );
+  }
+
+  addLevel(): void {
+    this.levelSet.levels.push({
+      name: 'New Level',
+      creator: null,
+      genre: null,
+      difficulty: null,
+      audio: null,
+      background: null,
+      lines: [],
+    });
+    this.save();
+  }
+
+  moveLevel(index: number, direction: number): void {
+    const target = index + direction;
+    if (target < 0) {
+      return;
+    }
+    if (target >= this.levelSet.levels.length) {
+      return;
+    }
+
+    const level = this.levelSet.levels[index];
+    this.levelSet.levels.splice(index, 1);
+    this.levelSet.levels.splice(target, 0, level);
+    this.save();
+  }
+
+  removeLevel(index: number): void {
+    const level = this.levelSet.levels[index];
+    if (confirm(`Are you sure you want to remove ${level.name}?`)) {
+      this.levelSet.levels.splice(index, 1);
+      this.save();
+    }
+  }
+}

+ 412 - 0
src/editor/lyrics.ts

@@ -0,0 +1,412 @@
+import * as level from '../level';
+import * as util from '../util';
+
+export class LyricsEditor {
+  containerElement: HTMLElement;
+  firstLine: LineEditor;
+  lastLine: LineEditor;
+
+  constructor(readonly playAt: (time: number, duration?: number) => void) {
+    this.containerElement = util.getElement(document, '.lyrics');
+    this.firstLine = new LineEditor(this);
+    this.lastLine = this.firstLine;
+    this.appendLine();
+  }
+
+  *[Symbol.iterator]() {
+    let line: LineEditor | undefined = this.firstLine;
+    while (line !== undefined) {
+      yield line;
+      line = line.nextLine;
+    }
+  }
+
+  addInterval(time: number): void {
+    const fixedTime = time.toFixed(2);
+    for (const line of this) {
+      const input = line.timeInput;
+      if (input.value === '') {
+        input.value = fixedTime;
+        line.adjustTimeInput();
+        return;
+      } else {
+        const value = parseFloat(input.value);
+        if (time < value) {
+          line.pushText('time', fixedTime);
+          return;
+        }
+      }
+    }
+    const newLine = this.appendLine();
+    newLine.timeInput.value = fixedTime;
+    newLine.adjustTimeInput();
+  }
+
+  appendLine(): LineEditor {
+    const newLine = new LineEditor(this);
+    this.lastLine.setNextLine(newLine);
+
+    this.toggleLine(this.lastLine, false);
+    this.toggleLine(newLine, true);
+
+    this.lastLine = newLine;
+    return newLine;
+  }
+
+  removeLine(line: LineEditor): boolean {
+    if (line === this.firstLine) {
+      if (line.nextLine === undefined) {
+        // this is the only line, so we just clear it
+        line.clear();
+        return false;
+      } else {
+        this.firstLine = line.nextLine;
+      }
+    } else if (line === this.lastLine) {
+      if (line.previousLine === this.firstLine) {
+        line.clear();
+        return false;
+      } else {
+        this.lastLine = line.previousLine!;
+      }
+    }
+
+    this.toggleLine(this.lastLine, true);
+
+    if (line.previousLine !== undefined) {
+      line.previousLine.setNextLine(line.nextLine);
+    }
+    line.elements.forEach((element) => {
+      element.remove();
+    });
+    return true;
+  }
+
+  toggleLine(line: LineEditor, disabled: boolean): void {
+    line.elements.forEach((element) => {
+      if (element instanceof HTMLInputElement && element.type === 'text') {
+        element.disabled = disabled;
+      }
+    });
+  }
+
+  clear(): void {
+    while (this.removeLine(this.lastLine)) {}
+  }
+
+  getDisplayForTime(time: number): string | null {
+    for (const line of this) {
+      if (time < parseFloat(line.timeInput.value)) {
+        return line.previousLine?.kanjiInput.value ?? null;
+      }
+    }
+    return null;
+  }
+
+  loadLines(lines: level.Line[]): void {
+    this.clear();
+    if (lines.length > 0) {
+      const firstLine = lines[0];
+      let start = 0;
+      if (
+        firstLine !== undefined &&
+        firstLine.kana === '@' &&
+        firstLine.kanji === '@'
+      ) {
+        start = 1;
+      }
+      for (let i = start; i < lines.length; ++i) {
+        if (i > start) {
+          this.appendLine();
+        }
+        this.lastLine.previousLine!.fromLine(lines[i]);
+        if (i === lines.length - 1) {
+          this.lastLine.timeInput.value = `${lines[i].end ?? ''}`;
+          this.lastLine.adjustTimeInput();
+        }
+      }
+    }
+  }
+
+  toLines(): level.Line[] {
+    const lines: level.Line[] = [];
+    for (const lineEditor of this) {
+      const line = lineEditor.toLine();
+      if (lineEditor === this.firstLine && line.start !== undefined) {
+        lines.push({
+          kana: '@',
+          kanji: '@',
+          start: 0,
+          end: line.start,
+        });
+      }
+      if (lineEditor !== this.lastLine) {
+        lines.push(line);
+      }
+    }
+    return lines;
+  }
+}
+
+type Part = 'time' | 'kana' | 'kanji';
+
+export class LineEditor {
+  previousLine?: LineEditor;
+  nextLine?: LineEditor;
+
+  elements: Element[];
+  timeInput: HTMLInputElement;
+  kanaInput: HTMLInputElement;
+  kanjiInput: HTMLInputElement;
+
+  constructor(readonly container: LyricsEditor, before?: Element) {
+    const fragment = util.loadTemplate(document, 'line');
+    this.elements = Array.from(fragment.children);
+    this.timeInput = util.getElement(fragment, '.time');
+    this.kanaInput = util.getElement(fragment, '.kana');
+    this.kanjiInput = util.getElement(fragment, '.kanji');
+
+    this.timeInput.addEventListener('input', () => {
+      this.adjustTimeInput();
+    });
+    this.kanaInput.addEventListener('keydown', (event) => {
+      this.handleKeyDown('kana', event);
+    });
+    this.kanaInput.addEventListener('paste', (event) => {
+      this.handlePaste('kana', event);
+    });
+    this.kanjiInput.addEventListener('keydown', (event) => {
+      this.handleKeyDown('kanji', event);
+    });
+    this.kanjiInput.addEventListener('paste', (event) => {
+      this.handlePaste('kanji', event);
+    });
+    util.getElement(fragment, '.play-section').addEventListener('click', () => {
+      const line = this.toLine();
+      if (line.start !== undefined) {
+        const duration =
+          line.end !== undefined ? line.end - line.start : undefined;
+        this.container.playAt(line.start, duration);
+      }
+    });
+    util
+      .getElement(fragment, '.remove-section')
+      .addEventListener('click', () => {
+        this.popText('time');
+      });
+
+    if (before) {
+      container.containerElement.insertBefore(fragment, before);
+    } else {
+      container.containerElement.appendChild(fragment);
+    }
+  }
+
+  getInput(part: Part): HTMLInputElement {
+    switch (part) {
+      case 'time':
+        return this.timeInput;
+      case 'kana':
+        return this.kanaInput;
+      case 'kanji':
+        return this.kanjiInput;
+    }
+  }
+
+  // adjust min value such that we validate and step adds/subtracts based on
+  // our input value
+  adjustTimeInput(): void {
+    if (this.timeInput.value !== '') {
+      const value = parseFloat(this.timeInput.value);
+      const step = parseFloat(this.timeInput.step);
+      this.timeInput.min = (value % step).toFixed(3);
+    }
+  }
+
+  handlePaste(part: Part, event: ClipboardEvent): void {
+    if (event.clipboardData !== null) {
+      event.preventDefault();
+      const paste = event.clipboardData.getData('text');
+      const lines = paste.split('\n');
+
+      if (lines.length === 0) {
+        return;
+      }
+
+      const input = this.getInput(part);
+      const currentText = input.value;
+
+      for (let i = lines.length - 1; i > 0; --i) {
+        this.pushText(part, lines[i]);
+      }
+
+      this.pushText(part, currentText + lines[0]);
+    }
+  }
+
+  handleKeyDown(part: Part, event: KeyboardEvent): void {
+    if (event.key === 'ArrowUp' && this.previousLine !== undefined) {
+      event.preventDefault();
+      const input = this.getInput(part);
+      const position = input.selectionStart ?? 0;
+
+      const prevInput = this.previousLine.getInput(part);
+      prevInput.focus();
+      prevInput.setSelectionRange(position, position);
+    } else if (event.key === 'ArrowDown' && this.nextLine !== undefined) {
+      event.preventDefault();
+      const input = this.getInput(part);
+      const position = input.selectionStart ?? 0;
+
+      const nextInput = this.nextLine.getInput(part);
+      nextInput.focus();
+      nextInput.setSelectionRange(position, position);
+    } else if (event.key === 'Enter') {
+      const input = this.getInput(part);
+      const text = input.value;
+      if (input.selectionStart !== null && input.selectionEnd !== null) {
+        const remaining = text.substring(0, input.selectionStart);
+        const afterNewline = text.substring(input.selectionEnd);
+        input.value = remaining;
+
+        const nextLine = this.ensureNextLine(part);
+        nextLine.pushText(part, afterNewline);
+
+        const nextInput = nextLine.getInput(part);
+        nextInput.focus();
+        nextInput.setSelectionRange(0, 0);
+      }
+    } else if (event.key === 'Backspace' && this.previousLine !== undefined) {
+      const input = this.getInput(part);
+      if (input.selectionStart === 0 && input.selectionEnd === 0) {
+        event.preventDefault();
+        const prevInput = this.previousLine.getInput(part);
+
+        const prevText = prevInput.value;
+        const prevLength = prevText.length;
+        prevInput.value = prevText + input.value;
+        this.popText(part);
+        prevInput.focus();
+        prevInput.setSelectionRange(prevLength, prevLength);
+      }
+    } else if (event.key === 'Delete' && this.nextLine !== undefined) {
+      const input = this.getInput(part);
+      const length = input.value.length;
+      if (input.selectionStart === length && input.selectionEnd === length) {
+        event.preventDefault();
+        const nextInput = this.nextLine.getInput(part);
+
+        input.value = input.value + nextInput.value;
+        input.setSelectionRange(length, length);
+        this.nextLine.popText(part);
+      }
+    }
+  }
+
+  pushText(part: Part, text: string): void {
+    const input = this.getInput(part);
+    const current = input.value;
+    input.value = text;
+    if (part === 'time') {
+      this.adjustTimeInput();
+    }
+
+    if (current === '') {
+      return;
+    }
+
+    const nextLine = this.ensureNextLine(part);
+    nextLine.pushText(part, current);
+  }
+
+  popText(part: Part): void {
+    const input = this.getInput(part);
+
+    if (this.nextLine === undefined) {
+      // we are the last line
+      if (part === 'time' && this.previousLine !== undefined) {
+        const { kanaInput, kanjiInput } = this.previousLine;
+        if (kanaInput.value === '' && kanjiInput.value === '') {
+          this.container.removeLine(this);
+        } else {
+          input.value = '';
+        }
+      } else {
+        input.value = '';
+      }
+    } else {
+      if (this.nextLine.nextLine === undefined) {
+        // the next line is the last one
+        if (
+          this.kanaInput.value === '' &&
+          this.kanjiInput.value === '' &&
+          this.nextLine.timeInput.value === ''
+        ) {
+          this.container.removeLine(this.nextLine);
+        } else {
+          input.value = this.nextLine.getInput(part).value;
+          this.nextLine.popText(part);
+        }
+      } else {
+        input.value = this.nextLine.getInput(part).value;
+        this.nextLine.popText(part);
+      }
+    }
+    if (part === 'time') {
+      this.adjustTimeInput();
+    }
+  }
+
+  ensureNextLine(part: Part): LineEditor {
+    if (part !== 'time' && this.nextLine === this.container.lastLine) {
+      this.container.appendLine();
+      return this.nextLine;
+    } else if (this.nextLine === undefined) {
+      return this.container.appendLine();
+    } else {
+      return this.nextLine;
+    }
+  }
+
+  setNextLine(nextLine?: LineEditor): void {
+    this.nextLine = nextLine;
+    if (this.nextLine !== undefined) {
+      this.nextLine.previousLine = this;
+    }
+  }
+
+  clear() {
+    this.timeInput.value = '';
+    this.kanaInput.value = '';
+    this.kanjiInput.value = '';
+  }
+
+  fromLine(line: level.Line) {
+    this.kanaInput.value = line.kana === '@' ? '' : line.kana;
+    this.kanjiInput.value = line.kanji === '@' ? '' : line.kanji;
+    if (line.start !== undefined) {
+      this.timeInput.value = `${line.start}`;
+      this.adjustTimeInput();
+    }
+  }
+
+  toLine(): level.Line {
+    const line: level.Line = {
+      kana: this.kanaInput.value || '@',
+      kanji: this.kanjiInput.value || '@',
+    };
+    const time = this.timeInput.value;
+    if (time !== '') {
+      const nextValue = this.nextLine?.timeInput?.value;
+      line.start = parseFloat(time);
+      if (nextValue) {
+        line.end = parseFloat(nextValue);
+      }
+    }
+    return line;
+  }
+
+  get time(): number {
+    return parseFloat(this.timeInput.value);
+  }
+}

+ 37 - 0
src/editor/util.ts

@@ -0,0 +1,37 @@
+import * as util from '../util';
+
+export function makePropertyEditor(
+  container: HTMLElement,
+  value: string | null,
+  save: (value: string) => void,
+  render: (value: string | null) => HTMLElement = (value) =>
+    util.createElement('span', value)
+) {
+  const display = render(value);
+  container.addEventListener(
+    'click',
+    () => {
+      const form = document.createElement('form');
+      form.addEventListener('submit', (event) => {
+        event.preventDefault();
+        save(input.value);
+        form.remove();
+        makePropertyEditor(container, input.value, save, render);
+      });
+
+      const input = document.createElement('input');
+      input.value = value ?? '';
+      const button = util.createElement('button', 'Save');
+
+      form.appendChild(input);
+      form.appendChild(button);
+
+      display.remove();
+      container.appendChild(form);
+      input.focus();
+    },
+    { once: true }
+  );
+
+  container.appendChild(display);
+}

+ 92 - 0
src/editor/waveForm.ts

@@ -0,0 +1,92 @@
+import * as audio from '../audio';
+
+export class WaveForm {
+  ctx: CanvasRenderingContext2D;
+  overlayCtx: CanvasRenderingContext2D;
+  track: audio.FileTrack | null = null;
+  data: Float32Array | null = null;
+  stride: number = 0;
+  currentSection: number = -1;
+
+  constructor(
+    readonly canvas: HTMLCanvasElement,
+    readonly overlay: HTMLCanvasElement,
+    readonly setTime: (time: number) => void
+  ) {
+    canvas.height = canvas.clientHeight;
+    canvas.width = canvas.clientWidth;
+    overlay.height = overlay.clientHeight;
+    overlay.width = overlay.clientWidth;
+    this.ctx = canvas.getContext('2d')!;
+    this.overlayCtx = overlay.getContext('2d')!;
+
+    this.ctx.fillStyle = 'forestgreen';
+    this.overlayCtx.fillStyle = 'rgba(255, 0, 0, 0.5)';
+
+    this.overlay.addEventListener('click', (event: MouseEvent) => {
+      let pos = event.clientX - this.overlay.offsetLeft;
+      let percentage = pos / this.overlay.width;
+      let time = this.currentSection * 5 + percentage * 5;
+      this.setTime(time);
+    });
+  }
+
+  clear(): void {
+    this.track = null;
+  }
+
+  setTrack(track: audio.FileTrack): void {
+    this.track = track;
+    this.stride = Math.floor(
+      (this.track.buffer.sampleRate / this.canvas.width) * 5
+    );
+    this.currentSection = -1;
+  }
+
+  timeToX(time: number): number {
+    return ((time - this.currentSection * 5) / 5) * this.canvas.width;
+  }
+
+  update(times: number[]): void {
+    const section = Math.floor(this.track!.getTime() / 5);
+
+    const height = this.canvas.height;
+    const midPoint = Math.floor(height / 2);
+    this.ctx.fillRect(0, midPoint, this.canvas.width, 1);
+    if (this.currentSection != section) {
+      this.data = this.track!.buffer.getChannelData(0);
+      this.ctx.clearRect(0, 0, this.canvas.width, height);
+      let offset = section * this.canvas.width * this.stride;
+      for (let i = 0; i < this.canvas.width; ++i) {
+        let index = offset + i * this.stride;
+        let max = -1;
+        let min = 1;
+        for (let j = index; j < index + this.stride; ++j) {
+          const value = this.data[j];
+          max = Math.max(value, max);
+          min = Math.min(value, min);
+        }
+
+        let positiveHeight = midPoint + Math.round(midPoint * max);
+        let negativeHeight = midPoint + Math.round(midPoint * min);
+
+        const barHeight = Math.max(1, positiveHeight - negativeHeight);
+        this.ctx.fillRect(i, negativeHeight, 1, barHeight);
+      }
+      this.currentSection = section;
+    }
+
+    let marker = this.timeToX(this.track!.getTime());
+    this.overlayCtx.clearRect(0, 0, this.canvas.width, height);
+    this.overlayCtx.fillRect(0, 0, marker, height);
+    times.forEach((time) => {
+      if (time > section * 5 && time <= section * 5 + 5) {
+        let x = this.timeToX(time);
+        this.overlayCtx.beginPath();
+        this.overlayCtx.moveTo(x, 0);
+        this.overlayCtx.lineTo(x, height);
+        this.overlayCtx.stroke();
+      }
+    });
+  }
+}

+ 9 - 0
src/util.ts

@@ -44,6 +44,15 @@ export function getElement<E extends HTMLElement>(
   return e as E;
 }
 
+export function createElement(
+  elementName: string,
+  value: string | null
+): HTMLElement {
+  const e = document.createElement(elementName);
+  e.textContent = value;
+  return e;
+}
+
 export function loadBackground(url: string): Promise<void> {
   if (url.includes('.')) {
     return new Promise((resolve, reject) => {