Compare commits
No commits in common. 'main' and 'feat/typescript' have entirely different histories.
main
...
feat/types
|
Before Width: | Height: | Size: 403 B |
|
Before Width: | Height: | Size: 408 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 24 KiB |
@ -1 +0,0 @@ |
||||
use flake |
||||
@ -1,23 +0,0 @@ |
||||
BUILD_DIR := build
|
||||
|
||||
.PHONY: all configure build clean run |
||||
|
||||
all: build |
||||
|
||||
configure: |
||||
cmake -S . -B $(BUILD_DIR) -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Debug \
|
||||
-DCMAKE_C_COMPILER=clang \
|
||||
-DCMAKE_CXX_COMPILER=clang++
|
||||
-DBUILD_CLI_VIS=1
|
||||
|
||||
build: configure |
||||
cmake --build $(BUILD_DIR)
|
||||
@# Symlink compile_commands.json to root so clangd picks it up
|
||||
@ln -sf $(BUILD_DIR)/compile_commands.json compile_commands.json
|
||||
|
||||
run: build |
||||
./$(BUILD_DIR)/myapp
|
||||
|
||||
clean: |
||||
rm -rf $(BUILD_DIR) compile_commands.json
|
||||
@ -1,28 +0,0 @@ |
||||
{ |
||||
description = "C++ dev shell with CMake and clangd"; |
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; |
||||
|
||||
outputs = { self, nixpkgs }: |
||||
let |
||||
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; |
||||
forAll = f: nixpkgs.lib.genAttrs systems (s: f nixpkgs.legacyPackages.${s}); |
||||
in { |
||||
devShells = forAll (pkgs: { |
||||
default = pkgs.mkShell { |
||||
packages = with pkgs; [ |
||||
cmake |
||||
clang-tools # provides clangd, clang-format, clang-tidy |
||||
llvmPackages.clang |
||||
ninja # optional but pairs well with cmake |
||||
catch2 # testing framework |
||||
]; |
||||
|
||||
shellHook = '' |
||||
export CC=clang |
||||
export CXX=clang++ |
||||
''; |
||||
}; |
||||
}); |
||||
}; |
||||
} |
||||
@ -1,96 +0,0 @@ |
||||
#ifndef MONOFORMAT_BITHELPERS_HPP |
||||
#define MONOFORMAT_BITHELPERS_HPP |
||||
|
||||
#include <cstddef> |
||||
#include <cstdint> |
||||
#include <climits> |
||||
#include <concepts> |
||||
|
||||
namespace monoformat { |
||||
|
||||
template<std::integral Int, std::integral OtherInt> |
||||
constexpr inline bool isBitSet(Int value, OtherInt bit) { |
||||
if (bit < 0 || bit >= static_cast<Int>(sizeof(Int) * CHAR_BIT)) [[unlikely]] { |
||||
return false; |
||||
} |
||||
|
||||
Int mask = Int{1} << static_cast<Int>(bit); |
||||
return (value & mask) == mask; |
||||
} |
||||
|
||||
template<std::integral OtherInt> |
||||
constexpr inline bool isBitSet(std::byte value, OtherInt bit) { |
||||
return isBitSet(static_cast<std::uint8_t>(value), bit); |
||||
} |
||||
|
||||
template<std::integral Int, std::integral OtherInt> |
||||
constexpr inline void setBit(Int& value, OtherInt bit) { |
||||
if (bit < 0 || bit >= static_cast<Int>(sizeof(Int) * CHAR_BIT)) [[unlikely]] { |
||||
return; |
||||
} |
||||
|
||||
Int mask = Int{1} << static_cast<Int>(bit); |
||||
value |= mask; |
||||
} |
||||
|
||||
template<std::integral OtherInt> |
||||
constexpr inline void setBit(std::byte& value, OtherInt bit) { |
||||
if (bit < 0 || bit >= CHAR_BIT) [[unlikely]] { |
||||
return; |
||||
} |
||||
|
||||
std::uint8_t mask = std::uint8_t{1} << static_cast<std::uint8_t>(bit); |
||||
value = static_cast<std::byte>(static_cast<std::uint8_t>(value) | mask); |
||||
} |
||||
|
||||
template<std::integral Int, std::integral OtherInt> |
||||
constexpr inline void unsetBit(Int& value, OtherInt bit) { |
||||
if (bit < 0 || bit >= static_cast<Int>(sizeof(Int) * CHAR_BIT)) [[unlikely]] { |
||||
return; |
||||
} |
||||
|
||||
Int mask = Int{1} << static_cast<Int>(bit); |
||||
value &= ~mask; |
||||
} |
||||
|
||||
template<std::integral OtherInt> |
||||
constexpr inline void unsetBit(std::byte& value, OtherInt bit) { |
||||
if (bit < 0 || bit >= CHAR_BIT) [[unlikely]] { |
||||
return; |
||||
} |
||||
|
||||
std::uint8_t mask = std::uint8_t{1} << static_cast<std::uint8_t>(bit); |
||||
value = static_cast<std::byte>(static_cast<std::uint8_t>(value) & ~mask); |
||||
} |
||||
|
||||
template<std::integral Int, std::integral OtherInt> |
||||
constexpr inline void maybeSetBit(Int& value, OtherInt bit, bool set) { |
||||
if (bit < 0 || bit >= static_cast<Int>(sizeof(Int) * CHAR_BIT)) [[unlikely]] { |
||||
return; |
||||
} |
||||
|
||||
Int mask = Int{1} << static_cast<Int>(bit); |
||||
if (set) { |
||||
value |= mask; |
||||
} else { |
||||
value &= ~mask; |
||||
} |
||||
} |
||||
|
||||
template<std::integral OtherInt> |
||||
constexpr inline void maybeSetBit(std::byte& value, OtherInt bit, bool set) { |
||||
if (bit < 0 || bit >= CHAR_BIT) [[unlikely]] { |
||||
return; |
||||
} |
||||
|
||||
std::uint8_t mask = std::uint8_t{1} << static_cast<std::uint8_t>(bit); |
||||
if (set) { |
||||
value = static_cast<std::byte>(static_cast<std::uint8_t>(value) | mask); |
||||
} else { |
||||
value = static_cast<std::byte>(static_cast<std::uint8_t>(value) & ~mask); |
||||
} |
||||
} |
||||
|
||||
} // namespace monoformat
|
||||
|
||||
#endif // MONOFORMAT_BITHELPERS_HPP
|
||||
@ -0,0 +1,201 @@ |
||||
<?php |
||||
|
||||
namespace monoformat { |
||||
|
||||
enum SectionType : int { |
||||
case AlwaysDrawn = 1; |
||||
case TimeBasedDrawn = 2; |
||||
case CustomFont = 32; |
||||
} |
||||
|
||||
enum ElementType : int { |
||||
case Image = 1; |
||||
case Animation = 2; |
||||
case HScrollImage = 3; |
||||
case VScrollImage = 4; |
||||
case Line = 5; |
||||
case ClippedText = 16; |
||||
case HScrollText = 17; |
||||
case CurrentTime = 32; |
||||
} |
||||
|
||||
enum LineStyle : int { |
||||
case Solid = 0; |
||||
} |
||||
|
||||
class Element { |
||||
public function serialize() { |
||||
return ""; |
||||
} |
||||
} |
||||
|
||||
class HScrollTextElement extends Element { |
||||
public $x = 0; |
||||
public $y = 0; |
||||
public $width = 0; |
||||
public $height = 0; |
||||
public $flags = 0; |
||||
public $scrollSpeed = 0; |
||||
public $fontIndex = 0; |
||||
public $text = ""; |
||||
|
||||
public function serialize() { |
||||
$len = strlen($this->text); |
||||
$result = pack("vvvvvCCvv", |
||||
17, |
||||
$this->x, |
||||
$this->y, |
||||
$this->width, |
||||
$this->height, |
||||
$this->flags, |
||||
$this->scrollSpeed, |
||||
$this->fontIndex, |
||||
$len); |
||||
$result .= (string) $this->text; |
||||
if ($len % 4 != 0) { |
||||
$n = 4 - ($len % 4); |
||||
for ($i = 0; $i < $n; ++$i) { |
||||
$result .= chr(0); |
||||
} |
||||
} |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
class CurrentTimeElement extends Element { |
||||
public $x = 0; |
||||
public $y = 0; |
||||
public $width = 0; |
||||
public $height = 0; |
||||
public $fontIndex = 0; |
||||
public $utcOffset = 0; |
||||
public $flags = 0; |
||||
|
||||
public function serialize() { |
||||
$result = pack("vvvvvvvv", |
||||
32, |
||||
$this->x, |
||||
$this->y, |
||||
$this->width, |
||||
$this->height, |
||||
$this->fontIndex, |
||||
$this->utcOffset, |
||||
$this->flags); |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
class Section { |
||||
public function serialize() { |
||||
return ""; |
||||
} |
||||
} |
||||
|
||||
class AlwaysDrawnSection extends Section { |
||||
public $drawOnFront = false; |
||||
public $drawOnBack = false; |
||||
public $clearBeforeDrawing = false; |
||||
public $elements = []; |
||||
|
||||
public function serialize() { |
||||
$flags = 0; |
||||
if ($this->drawOnFront) { |
||||
$flags |= 0x01; |
||||
} |
||||
if ($this->drawOnBack) { |
||||
$flags |= 0x02; |
||||
} |
||||
if ($this->clearBeforeDrawing) { |
||||
$flags |= 0x04; |
||||
} |
||||
$inner = pack("vv", $flags, count($this->elements)); |
||||
foreach ($this->elements as $element) { |
||||
$inner .= $element->serialize(); |
||||
} |
||||
$len = strlen($inner) + 4; |
||||
$len = substr(pack("V", $len), 0, 3); |
||||
return pack("C", 1) . $len . $inner; |
||||
} |
||||
} |
||||
|
||||
class File { |
||||
public $sections = []; |
||||
|
||||
public function serialize() { |
||||
$nSections = count($this->sections); |
||||
$result = "\xAF\x7E\x2B\x63"; |
||||
$result .= pack("Vvv", 1, $nSections, 0); |
||||
foreach ($this->sections as $section) { |
||||
$result .= $section->serialize(); |
||||
} |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
} // namespace monoformat |
||||
|
||||
namespace { |
||||
|
||||
$roomName= "BOOL"; |
||||
|
||||
$data = json_decode(file_get_contents("https://cfp.cttue.de/tdf5/schedule/export/schedule.json"), true); |
||||
$talks = []; |
||||
$now = new DateTimeImmutable("now"); |
||||
foreach ($data["schedule"]["conference"]["days"] as $day) { |
||||
foreach ($day["rooms"][$roomName] as $t) { |
||||
[$h, $m] = explode(":", $t["duration"]); |
||||
$duration = $h * 3600 + $m * 60; |
||||
$duration = DateInterval::createFromDateString("$duration sec"); |
||||
$talk = [ |
||||
"date" => new DateTimeImmutable($t["date"]), |
||||
"duration" => $duration, |
||||
"title" => $t["title"], |
||||
]; |
||||
$talkEnd = $talk["date"]->add($talk["duration"]); |
||||
if ($talkEnd < $now) { |
||||
continue; |
||||
} |
||||
if ($talk["date"] > $now && count($talks) > 2) { |
||||
break; |
||||
} |
||||
array_push($talks, $talk); |
||||
} |
||||
} |
||||
|
||||
$elements = []; |
||||
|
||||
function putText($x, $y, $width, $height, $text) { |
||||
global $elements; |
||||
|
||||
$e = new monoformat\HScrollTextElement(); |
||||
$e->x = $x; |
||||
$e->y = $y; |
||||
$e->width = $width; |
||||
$e->height = $height; |
||||
$e->flags = 0; |
||||
$e->scrollSpeed = 15; |
||||
$e->fontIndex = 0; |
||||
$e->text = $text; |
||||
array_push($elements, $e); |
||||
} |
||||
|
||||
$i = 0; |
||||
foreach ($talks as $talk) { |
||||
putText(10, $i * 20, 25, 20, $talk["date"]->format("H:i")); |
||||
putText(35, $i * 20, 85, 20, $talk["title"]); |
||||
++$i; |
||||
} |
||||
|
||||
$section = new monoformat\AlwaysDrawnSection(); |
||||
$section->drawOnFront = true; |
||||
$section->drawOnBack = true; |
||||
$section->clearBeforeDrawing = true; |
||||
$section->elements = $elements; |
||||
|
||||
$file = new monoformat\File(); |
||||
array_push($file->sections, $section); |
||||
|
||||
print($file->serialize()); |
||||
} // global |
||||
|
||||
?> |
||||
@ -1,43 +0,0 @@ |
||||
<?php |
||||
|
||||
namespace monoformat { |
||||
|
||||
function isBitSet(int $value, int $bit) { |
||||
if ($bit < 0 || $bit >= (PHP_INT_SIZE * 8)) { |
||||
return false; |
||||
} |
||||
$mask = 1 << $bit; |
||||
return ($value & $mask) == $mask; |
||||
} |
||||
|
||||
function setBit(int &$value, int $bit) { |
||||
if ($bit < 0 || $bit >= (PHP_INT_SIZE * 8)) { |
||||
return; |
||||
} |
||||
$mask = 1 << $bit; |
||||
$value |= $mask; |
||||
} |
||||
|
||||
function unsetBit(int &$value, int $bit) { |
||||
if ($bit < 0 || $bit >= (PHP_INT_SIZE * 8)) { |
||||
return; |
||||
} |
||||
$mask = 1 << $bit; |
||||
$value &= ~$mask; |
||||
} |
||||
|
||||
function maybeSetBit(int &$value, int $bit, int $set) { |
||||
if ($bit < 0 || $bit >= (PHP_INT_SIZE * 8)) { |
||||
return; |
||||
} |
||||
$mask = 1 << $bit; |
||||
if ($set) { |
||||
$value |= $mask; |
||||
} else { |
||||
$value &= ~$mask; |
||||
} |
||||
} |
||||
|
||||
} // namespace monoformat |
||||
|
||||
?> |
||||
@ -1,213 +0,0 @@ |
||||
<?php |
||||
|
||||
namespace monoformat { |
||||
|
||||
include_once("monoformat_bithelpers.php"); |
||||
|
||||
trait FlagStruct { |
||||
public static function fromJSON(array $json) { |
||||
$myClass = static::class; |
||||
$reflect = new \ReflectionClass($myClass); |
||||
$properties = $reflect->getProperties(\ReflectionProperty::IS_PUBLIC); |
||||
$result = new $myClass(); |
||||
foreach ($properties as $property) { |
||||
$name = $property->getName(); |
||||
if (array_key_exists($name, $json)) { |
||||
$result->$name = (bool) $json[$name]; |
||||
} |
||||
} |
||||
return $result; |
||||
} |
||||
|
||||
public function toJSON(): array { |
||||
$result = []; |
||||
$myClass = static::class; |
||||
$reflect = new \ReflectionClass($myClass); |
||||
$properties = $reflect->getProperties(\ReflectionProperty::IS_PUBLIC); |
||||
foreach ($properties as $property) { |
||||
$name = $property->getName(); |
||||
$result[$name] = $this->$name; |
||||
} |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
enum SectionType : int { |
||||
case AlwaysDrawn = 1; |
||||
case TimeBasedDrawn = 2; |
||||
case MultiTimeBasedDrawn = 3; |
||||
case ExpiryDate = 31; |
||||
case CustomFont = 32; |
||||
|
||||
public static function fromName(string $name): SectionType { |
||||
foreach (self::cases() as $case) { |
||||
if ($case->name === $name) { |
||||
return $case; |
||||
} |
||||
} |
||||
throw new \ValueError("$name is not a valid backing value for enum " . self::class); |
||||
} |
||||
} |
||||
|
||||
enum ElementType : int { |
||||
case Image = 1; |
||||
case Animation = 2; |
||||
case HScrollImage = 3; |
||||
case VScrollImage = 4; |
||||
case Line = 5; |
||||
case Box = 6; |
||||
case ClippedText = 16; |
||||
case HScrollText = 17; |
||||
//case VScrollText = 18; |
||||
case CurrentTime = 32; |
||||
|
||||
public static function fromName(string $name): SectionType { |
||||
foreach (self::cases() as $case) { |
||||
if ($case->name === $name) { |
||||
return $case; |
||||
} |
||||
} |
||||
throw new \ValueError("$name is not a valid backing value for enum " . self::class); |
||||
} |
||||
} |
||||
|
||||
class ScrollFlags { |
||||
use FlagStruct; |
||||
|
||||
public bool $endless = false; |
||||
public bool $invertDirection = false; |
||||
public bool $padBefore = false; |
||||
public bool $padAfter = false; |
||||
|
||||
public static function fromBitField(int $bitfield): ScrollFlags { |
||||
$result = new ScrollFlags(); |
||||
$result->endless = isBitSet($bitfield, 0); |
||||
$result->invertDirection = isBitSet($bitfield, 1); |
||||
$result->padBefore = isBitSet($bitfield, 2); |
||||
$result->padAfter = isBitSet($bitfield, 3); |
||||
return $result; |
||||
} |
||||
|
||||
public function toBitField(): int { |
||||
$result = 0; |
||||
maybeSetBit($result, 0, $this->endless); |
||||
maybeSetBit($result, 1, $this->invertDirection); |
||||
maybeSetBit($result, 2, $this->padBefore); |
||||
maybeSetBit($result, 3, $this->padAfter); |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
enum LineStyle : int { |
||||
case Solid = 0; |
||||
|
||||
public static function fromName(string $name): SectionType { |
||||
foreach (self::cases() as $case) { |
||||
if ($case->name === $name) { |
||||
return $case; |
||||
} |
||||
} |
||||
throw new \ValueError("$name is not a valid backing value for enum " . self::class); |
||||
} |
||||
} |
||||
|
||||
class LineFlags { |
||||
use FlagStruct; |
||||
|
||||
public bool $dark = false; |
||||
|
||||
public static function fromBitField(int $bitfield): LineFlags { |
||||
$result = new LineFlags(); |
||||
$result->dark = isBitSet($bitfield, 0); |
||||
return $result; |
||||
} |
||||
|
||||
public function toBitField(): int { |
||||
$result = 0; |
||||
maybeSetBit($result, 0, $this->dark); |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
enum FillPatternStyle : int { |
||||
case Solid = 0; |
||||
|
||||
public static function fromName(string $name): SectionType { |
||||
foreach (self::cases() as $case) { |
||||
if ($case->name === $name) { |
||||
return $case; |
||||
} |
||||
} |
||||
throw new \ValueError("$name is not a valid backing value for enum " . self::class); |
||||
} |
||||
} |
||||
|
||||
class FillFlags { |
||||
use FlagStruct; |
||||
|
||||
public bool $dark = false; |
||||
|
||||
public static function fromBitField(int $bitfield): FillFlags { |
||||
$result = new FillFlags(); |
||||
$result->dark = isBitSet($bitfield, 0); |
||||
return $result; |
||||
} |
||||
|
||||
public function toBitField(): int { |
||||
$result = 0; |
||||
maybeSetBit($result, 0, $this->dark); |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
class TextFlags { |
||||
use FlagStruct; |
||||
|
||||
public bool $dark = false; |
||||
public bool $autoWordWrap = false; |
||||
|
||||
public static function fromBitField(int $bitfield): TextFlags { |
||||
$result = new TextFlags(); |
||||
$result->dark = isBitSet($bitfield, 0); |
||||
$result->autoWordWrap = isBitSet($bitfield, 1); |
||||
return $result; |
||||
} |
||||
|
||||
public function toBitField(): int { |
||||
$result = 0; |
||||
maybeSetBit($result, 0, $this->dark); |
||||
maybeSetBit($result, 1, $this->autoWordWrap); |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
class TimeDisplayFlags { |
||||
use FlagStruct; |
||||
|
||||
public bool $use12h = false; |
||||
public bool $showHours = false; |
||||
public bool $showMinutes = false; |
||||
public bool $showSeconds = false; |
||||
|
||||
public static function fromBitField(int $bitfield): TimeDisplayFlags { |
||||
$result = new TimeDisplayFlags(); |
||||
$result->use12h = isBitSet($bitfield, 0); |
||||
$result->showHours = isBitSet($bitfield, 1); |
||||
$result->showMinutes = isBitSet($bitfield, 2); |
||||
$result->showSeconds = isBitSet($bitfield, 3); |
||||
return $result; |
||||
} |
||||
|
||||
public function toBitField(): int { |
||||
$result = 0; |
||||
maybeSetBit($result, 0, $this->use12h); |
||||
maybeSetBit($result, 1, $this->showHours); |
||||
maybeSetBit($result, 2, $this->showMinutes); |
||||
maybeSetBit($result, 3, $this->showSeconds); |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
} // namespace monoformat |
||||
|
||||
?> |
||||
@ -1,203 +0,0 @@ |
||||
<?php |
||||
|
||||
$roomMapping = [ |
||||
]; |
||||
|
||||
$passThroughMapping = [ |
||||
]; |
||||
|
||||
include_once("monoformat_schema.php"); |
||||
include_once("monoformat_structured.php"); |
||||
|
||||
function replaceTalkInfo($str, $talks) { |
||||
return preg_replace_callback("/\\$\\{([^}]*)\\}/", function ($matches) use ($talks) { |
||||
if (str_contains($matches[1], ".")) { |
||||
[$id, $what] = explode(".", $matches[1], 2); |
||||
$id = (int) $id; |
||||
if ($id < 0 || $id >= count($talks)) { |
||||
return ""; |
||||
} |
||||
$talk = $talks[$id]; |
||||
if (array_key_exists($what, $talk)) { |
||||
return $talk[$what]; |
||||
} else { |
||||
return ""; |
||||
} |
||||
} else { |
||||
return ""; |
||||
} |
||||
}, $str); |
||||
} |
||||
|
||||
if (isset($_GET["mac"]) and array_key_exists($_GET["mac"], $passThroughMapping)) { |
||||
$fn = $passThroughMapping[$_GET["mac"]]; |
||||
if (is_dir($fn)) { |
||||
$d = opendir($fn); |
||||
$files = []; |
||||
while (($e = readdir($d)) !== false) { |
||||
if (str_ends_with($e, ".bin")) { |
||||
array_push($files, $fn . "/" . $e); |
||||
} |
||||
} |
||||
sort($files); |
||||
if (!count($files)) { |
||||
die("No files found"); |
||||
} |
||||
$n = (time() / 60) % count($files); |
||||
$fn = $files[$n]; |
||||
} |
||||
Header("Content-Type: application/octet-stream"); |
||||
readfile($fn); |
||||
exit(0); |
||||
} |
||||
|
||||
if (!isset($_GET["mac"]) or !array_key_exists($_GET["mac"], $roomMapping)) { |
||||
/* We didn't find this device in the mapping, so let's just |
||||
* send a default file to the user. |
||||
*/ |
||||
Header("Content-Type: application/octet-stream"); |
||||
readfile(dirname(__FILE__) . "/noroom.bin"); |
||||
exit(0); |
||||
} |
||||
|
||||
$roomGUID = strtolower($roomMapping[$_GET["mac"]]); |
||||
|
||||
$pretalx = json_decode(file_get_contents("schedule.json"), true); |
||||
|
||||
$roomName = null; |
||||
foreach ($pretalx["schedule"]["conference"]["rooms"] as $room) { |
||||
$thisRoomGUID = strtolower($room["guid"]); |
||||
if ($thisRoomGUID === $roomGUID) { |
||||
$roomName = $room["name"]; |
||||
break; |
||||
} |
||||
} |
||||
if ($roomName === null) { |
||||
die("Room not found!"); |
||||
} |
||||
|
||||
$talks = []; |
||||
$now = new DateTimeImmutable("now"); |
||||
|
||||
foreach ($pretalx["schedule"]["conference"]["days"] as $day) { |
||||
if (!array_key_exists($roomName, $day["rooms"])) { |
||||
continue; |
||||
} |
||||
$roomTalks = $day["rooms"][$roomName]; |
||||
foreach ($roomTalks as $t) { |
||||
[$h, $m] = explode(":", $t["duration"]); |
||||
$duration = $h * 3600 + $m * 60; |
||||
$duration = DateInterval::createFromDateString("$duration sec"); |
||||
$speakers = []; |
||||
foreach ($t["persons"] as $person) { |
||||
array_push($speakers, $person["name"]); |
||||
} |
||||
$talkBegin = new DateTimeImmutable($t["date"]); |
||||
$talkEnd = $talkBegin->add($duration); |
||||
if ($talkEnd < $now) { |
||||
continue; |
||||
} |
||||
$talk = [ |
||||
"Time" => $talkBegin->format("H:i"), |
||||
"Duration" => $duration->format("H:i"), |
||||
"Persons" => implode(", ", $speakers), |
||||
"Title" => $t["title"], |
||||
"_start" => $talkBegin, |
||||
"_end" => $talkEnd, |
||||
]; |
||||
array_push($talks, $talk); |
||||
} |
||||
} |
||||
|
||||
$template = file_get_contents(dirname(__FILE__) . "/template.bin"); |
||||
$template = monoformat\parseFile($template); |
||||
|
||||
if (count($template->sections) != 1 or $template->sections[0]->sectionType() != monoformat\SectionType::AlwaysDrawn) { |
||||
die("Invalid section in template file"); |
||||
} |
||||
|
||||
$elements = []; |
||||
for ($i = 0; $i < $template->sections[0]->elementCount(); ++$i) { |
||||
array_push($elements, $template->sections[0]->elementAt($i)); |
||||
} |
||||
|
||||
$end_template = file_get_contents(dirname(__FILE__) . "/endscreen.bin"); |
||||
$end_template = monoformat\parseFile($end_template); |
||||
|
||||
if (count($end_template->sections) != 1 or $end_template->sections[0]->sectionType() != monoformat\SectionType::AlwaysDrawn) { |
||||
die("Invalid section in end screen template file"); |
||||
} |
||||
|
||||
$end_elements = []; |
||||
for ($i = 0; $i < $end_template->sections[0]->elementCount(); ++$i) { |
||||
array_push($end_elements, $end_template->sections[0]->elementAt($i)); |
||||
} |
||||
|
||||
$n = 42; |
||||
$last = false; |
||||
if ($n > count($talks)) { |
||||
$n = count($talks); |
||||
$last = true; |
||||
} |
||||
|
||||
$lastEnd = 0; |
||||
$outputFile = new monoformat\File(); |
||||
if (!$last) { |
||||
$section = new monoformat\ExpiryDateSection(); |
||||
$section->setExpiryDate($talks[$n - 1]["_end"]->getTimestamp()); |
||||
array_push($outputFile->sections, $section); |
||||
} |
||||
for ($i = 0; $i < $n; ++$i) { |
||||
$subTalks = array_slice($talks, $i); |
||||
$end = $subTalks[0]["_end"]->getTimestamp(); |
||||
$section = new monoformat\TimeBasedDrawnSection(); |
||||
$section->setDrawOnFront(true); |
||||
$section->setDrawOnBack(true); |
||||
$section->setClearBeforeDrawing(true); |
||||
$section->setStartTimestamp($lastEnd); |
||||
$section->setEndTimestamp($end); |
||||
foreach ($elements as $element) { |
||||
if ($element->elementType() == monoformat\ElementType::ClippedText) { |
||||
$x = $element->x(); |
||||
$y = $element->y(); |
||||
$width = $element->width(); |
||||
$height = $element->height(); |
||||
$textFlags = $element->textFlags(); |
||||
$fontIndex = $element->fontIndex(); |
||||
$text = replaceTalkInfo($element->text(), $subTalks); |
||||
$element = new monoformat\ClippedTextElement($x, $y, $width, $height, $textFlags, $fontIndex, $text); |
||||
} else if ($element->elementType() == monoformat\ElementType::HScrollText) { |
||||
$x = $element->x(); |
||||
$y = $element->y(); |
||||
$width = $element->width(); |
||||
$height = $element->height(); |
||||
$textFlags = $element->textFlags(); |
||||
$flags = $element->flags(); |
||||
$scrollSpeed = $element->scrollSpeed(); |
||||
$fontIndex = $element->fontIndex(); |
||||
$text = replaceTalkInfo($element->text(), $subTalks) . " "; |
||||
$element = new monoformat\HScrollTextElement($x, $y, $width, $height, $textFlags, $flags, $scrollSpeed, $fontIndex, $text); |
||||
} |
||||
$section->appendElement($element); |
||||
} |
||||
array_push($outputFile->sections, $section); |
||||
$lastEnd = $end; |
||||
} |
||||
if ($last) { |
||||
$section = new monoformat\TimeBasedDrawnSection(); |
||||
$section->setDrawOnFront(true); |
||||
$section->setDrawOnBack(true); |
||||
$section->setClearBeforeDrawing(true); |
||||
$section->setStartTimestamp($lastEnd); |
||||
// Set this far enough in the future that it doesn't matter |
||||
$section->setEndTimestamp($lastEnd + 86400 * 300); |
||||
foreach ($end_elements as $element) { |
||||
$section->appendElement($element); |
||||
} |
||||
array_push($outputFile->sections, $section); |
||||
} |
||||
|
||||
Header("Content-Type: application/octet-stream"); |
||||
print(monoformat\serializeFile($outputFile)); |
||||
|
||||
?> |
||||
@ -1,37 +0,0 @@ |
||||
# dependencies (bun install) |
||||
node_modules |
||||
|
||||
# output |
||||
out |
||||
dist |
||||
public |
||||
*.tgz |
||||
|
||||
# code coverage |
||||
coverage |
||||
*.lcov |
||||
|
||||
# logs |
||||
logs |
||||
_.log |
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json |
||||
|
||||
# dotenv environment variable files |
||||
.env |
||||
.env.development.local |
||||
.env.test.local |
||||
.env.production.local |
||||
.env.local |
||||
|
||||
# caches |
||||
.eslintcache |
||||
.cache |
||||
*.tsbuildinfo |
||||
|
||||
# IntelliJ based IDEs |
||||
.idea |
||||
|
||||
# Finder (MacOS) folder config |
||||
.DS_Store |
||||
.claude/ |
||||
.claude/settings.local.json |
||||
@ -1,106 +0,0 @@ |
||||
|
||||
Default to using Bun instead of Node.js. |
||||
|
||||
- Use `bun <file>` instead of `node <file>` or `ts-node <file>` |
||||
- Use `bun test` instead of `jest` or `vitest` |
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild` |
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` |
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>` |
||||
- Use `bunx <package> <command>` instead of `npx <package> <command>` |
||||
- Bun automatically loads .env, so don't use dotenv. |
||||
|
||||
## APIs |
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`. |
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`. |
||||
- `Bun.redis` for Redis. Don't use `ioredis`. |
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`. |
||||
- `WebSocket` is built-in. Don't use `ws`. |
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile |
||||
- Bun.$`ls` instead of execa. |
||||
|
||||
## Testing |
||||
|
||||
Use `bun test` to run tests. |
||||
|
||||
```ts#index.test.ts |
||||
import { test, expect } from "bun:test"; |
||||
|
||||
test("hello world", () => { |
||||
expect(1).toBe(1); |
||||
}); |
||||
``` |
||||
|
||||
## Frontend |
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind. |
||||
|
||||
Server: |
||||
|
||||
```ts#index.ts |
||||
import index from "./index.html" |
||||
|
||||
Bun.serve({ |
||||
routes: { |
||||
"/": index, |
||||
"/api/users/:id": { |
||||
GET: (req) => { |
||||
return new Response(JSON.stringify({ id: req.params.id })); |
||||
}, |
||||
}, |
||||
}, |
||||
// optional websocket support |
||||
websocket: { |
||||
open: (ws) => { |
||||
ws.send("Hello, world!"); |
||||
}, |
||||
message: (ws, message) => { |
||||
ws.send(message); |
||||
}, |
||||
close: (ws) => { |
||||
// handle close |
||||
} |
||||
}, |
||||
development: { |
||||
hmr: true, |
||||
console: true, |
||||
} |
||||
}) |
||||
``` |
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle. |
||||
|
||||
```html#index.html |
||||
<html> |
||||
<body> |
||||
<h1>Hello, world!</h1> |
||||
<script type="module" src="./frontend.tsx"></script> |
||||
</body> |
||||
</html> |
||||
``` |
||||
|
||||
With the following `frontend.tsx`: |
||||
|
||||
```tsx#frontend.tsx |
||||
import React from "react"; |
||||
import { createRoot } from "react-dom/client"; |
||||
|
||||
// import .css files directly and it works |
||||
import './index.css'; |
||||
|
||||
const root = createRoot(document.body); |
||||
|
||||
export default function Frontend() { |
||||
return <h1>Hello, world!</h1>; |
||||
} |
||||
|
||||
root.render(<Frontend />); |
||||
``` |
||||
|
||||
Then, run index.ts |
||||
|
||||
```sh |
||||
bun --hot ./index.ts |
||||
``` |
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. |
||||
@ -1,15 +0,0 @@ |
||||
# libmonoformat-ts |
||||
|
||||
To install dependencies: |
||||
|
||||
```bash |
||||
bun install |
||||
``` |
||||
|
||||
To run: |
||||
|
||||
```bash |
||||
bun run index.ts |
||||
``` |
||||
|
||||
This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. |
||||
@ -1,37 +0,0 @@ |
||||
{ |
||||
"lockfileVersion": 1, |
||||
"configVersion": 1, |
||||
"workspaces": { |
||||
"": { |
||||
"name": "libmonoformat", |
||||
"dependencies": { |
||||
"@types/mithril": "^2.2.8", |
||||
"libmonoformat": "../ts", |
||||
"mithril": "^2.3.8", |
||||
}, |
||||
"devDependencies": { |
||||
"@types/bun": "latest", |
||||
}, |
||||
"peerDependencies": { |
||||
"typescript": "^5", |
||||
}, |
||||
}, |
||||
}, |
||||
"packages": { |
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], |
||||
|
||||
"@types/mithril": ["@types/mithril@2.2.8", "", {}, "sha512-FN9Tv1+Nlr0LNPGnIL/xOxLJfu5WW2n8HAFeo4yxF+/O0per/8g080xlXoo+xj8baowAcfsNI3k80DxyLY34gQ=="], |
||||
|
||||
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], |
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], |
||||
|
||||
"libmonoformat": ["libmonoformat@file:../ts", { "dependencies": { "@types/mithril": "^2.2.8", "mithril": "^2.3.8" }, "devDependencies": { "@types/bun": "latest" }, "peerDependencies": { "typescript": "^5" } }], |
||||
|
||||
"mithril": ["mithril@2.3.8", "", {}, "sha512-za/Yo7qXEckjm5syrSfaaI9Utf4tCUT3T1IOIYqH6Lrj7G0OZuYYLAY9SV4ygoaAf0+CNqU92MBt+7pmo53JVQ=="], |
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], |
||||
|
||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], |
||||
} |
||||
} |
||||
@ -1,117 +0,0 @@ |
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
|
||||
<head> |
||||
<meta charset="UTF-8" /> |
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" /> |
||||
<title>MonoDisplay Editor</title> |
||||
<script>!function () { var t = localStorage.getItem('theme'); t && document.documentElement.setAttribute('data-theme', t) }();</script> |
||||
<link href="style.css" rel="stylesheet" /> |
||||
<link href="mobile.css" rel="stylesheet" /> |
||||
</head> |
||||
|
||||
<body> |
||||
<div id="topbar"> |
||||
<span class="app-name">MonoDisplay</span> |
||||
<div class="tb-sep"></div> |
||||
<button class="tb-btn" onclick="document.getElementById('file-input').click()"> |
||||
<svg viewBox="0 0 24 24"> |
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> |
||||
<polyline points="17 8 12 3 7 8" /> |
||||
<line x1="12" y1="3" x2="12" y2="15" /> |
||||
</svg> |
||||
Load .bin |
||||
</button> |
||||
<input type="file" id="file-input" accept=".bin" onchange="loadBin(this)" /> |
||||
<button class="tb-btn" onclick="exportBin()"> |
||||
<svg viewBox="0 0 24 24"> |
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> |
||||
<polyline points="7 10 12 15 17 10" /> |
||||
<line x1="12" y1="15" x2="12" y2="3" /> |
||||
</svg> |
||||
Export .bin |
||||
</button> |
||||
<div class="tb-sep"></div> |
||||
<button class="tb-btn" onclick="clearAll()"> |
||||
<svg viewBox="0 0 24 24"> |
||||
<polyline points="3 6 5 6 21 6" /> |
||||
<path d="M19 6l-1 14H6L5 6" /> |
||||
<path d="M10 11v6M14 11v6" /> |
||||
</svg> |
||||
Clear all |
||||
</button> |
||||
<button class="tb-btn" onclick="addSection()"> |
||||
<svg viewBox="0 0 24 24"> |
||||
<line x1="12" y1="5" x2="12" y2="19" /> |
||||
<line x1="5" y1="12" x2="19" y2="12" /> |
||||
</svg> |
||||
Add section |
||||
</button> |
||||
<span id="filename">untitled</span> |
||||
<div style="flex:1"></div> |
||||
<div class="tb-sep"></div> |
||||
<button class="tb-btn" id="theme-toggle" onclick="cycleTheme()">⊙ auto</button> |
||||
</div> |
||||
<div id="main"> |
||||
<div id="left"> |
||||
<span class="pv-label">preview · <select onchange="changeDisplayLayout()"> |
||||
<option>120 × 60</option> |
||||
<option>120 × 40</option> |
||||
</select></span> |
||||
</span> |
||||
<div id="display-box"> |
||||
<canvas id="canvas_root" width="120" height="60"></canvas> |
||||
</div> |
||||
<span class="pv-label">demos</span> |
||||
<div id="demo-btns"></div> |
||||
<div id="sec-meta"> |
||||
<div class="meta-row"> |
||||
<label>Selected section</label> |
||||
<span class="meta-val green" id="meta-name">-</span> |
||||
</div> |
||||
<div class="meta-row"> |
||||
<label>Elements</label> |
||||
<span class="meta-val" id="meta-count">-</span> |
||||
</div> |
||||
<div class="meta-row"> |
||||
<label>Section flags</label> |
||||
<label class="flag-check"> |
||||
<input type="checkbox" id="flag-drawFront" onchange="setSectionFlag('drawFront',this.checked)" /> |
||||
drawFront |
||||
</label> |
||||
<label class="flag-check" style="margin-left:-8px"> |
||||
<input type="checkbox" id="flag-drawBack" onchange="setSectionFlag('drawBack',this.checked)" /> |
||||
drawBack |
||||
</label> |
||||
<label class="flag-check" style="margin-left:-8px"> |
||||
<input type="checkbox" id="flag-clearBuffer" onchange="setSectionFlag('clearBuffer',this.checked)" /> |
||||
clearBuffer |
||||
</label> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div id="right"> |
||||
<div id="right-hdr"> |
||||
<span class="rh-title">sections</span> |
||||
<button class="tb-btn" id="mob-add-section" onclick="addSection()"> |
||||
<svg viewBox="0 0 24 24"> |
||||
<line x1="12" y1="5" x2="12" y2="19" /> |
||||
<line x1="5" y1="12" x2="19" y2="12" /> |
||||
</svg> |
||||
</button> |
||||
</div> |
||||
<div id="sections-wrap"> |
||||
<div class="empty-state">No sections yet.<br />Use <b>Add section</b> to get started.</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div id="confirm-overlay"> |
||||
<div id="confirm-box"> |
||||
<div id="confirm-msg"></div> |
||||
<div id="confirm-btns"></div> |
||||
</div> |
||||
</div> |
||||
<script src="./public/mono-display.js"></script> |
||||
</body> |
||||
|
||||
</html> |
||||
@ -1,215 +0,0 @@ |
||||
<?php |
||||
session_start(); |
||||
|
||||
define('PASSWORD', 'password'); |
||||
define('FILES_DIR', __DIR__ . '/avj2305'); |
||||
define('ACTIVE_FILE', __DIR__ . '/active.txt'); |
||||
|
||||
// ============================================================================= |
||||
// HELPERS |
||||
// ============================================================================= |
||||
|
||||
function redirect(string $url): never { |
||||
header('Location: ' . $url); |
||||
exit; |
||||
} |
||||
|
||||
function require_auth(): void { |
||||
if (empty($_SESSION['auth'])) redirect('?login'); |
||||
} |
||||
|
||||
function get_active(): string { |
||||
if (!file_exists(ACTIVE_FILE)) return ''; |
||||
$v = trim(file_get_contents(ACTIVE_FILE)); |
||||
return $v !== '' ? $v : ''; |
||||
} |
||||
|
||||
function set_active(string $filename): void { |
||||
file_put_contents(ACTIVE_FILE, $filename); |
||||
} |
||||
|
||||
function get_bin_files(): array { |
||||
$files = glob(FILES_DIR . '/*.bin'); |
||||
return $files ? array_map('basename', $files) : []; |
||||
} |
||||
|
||||
function safe_filename(string $name): bool { |
||||
return $name === basename($name) |
||||
&& str_ends_with($name, '.bin') |
||||
&& !str_contains($name, "\0"); |
||||
} |
||||
|
||||
function has_png(string $bin_basename): bool { |
||||
$png = substr($bin_basename, 0, -4) . '.png'; |
||||
return file_exists(FILES_DIR . '/' . $png); |
||||
} |
||||
|
||||
function png_path(string $bin_basename): string { |
||||
return FILES_DIR . '/' . substr($bin_basename, 0, -4) . '.png'; |
||||
} |
||||
|
||||
// ============================================================================= |
||||
// ACTIONS (POST handlers - redirect, never render) |
||||
// ============================================================================= |
||||
|
||||
function action_login(): never { |
||||
if (hash_equals(PASSWORD, $_POST['password'] ?? '')) { |
||||
$_SESSION['auth'] = true; |
||||
redirect('?edit'); |
||||
} |
||||
redirect('?login&err=1'); |
||||
} |
||||
|
||||
function action_set_file(): never { |
||||
require_auth(); |
||||
$f = $_POST['file'] ?? ''; |
||||
if (safe_filename($f) && file_exists(FILES_DIR . '/' . $f)) { |
||||
set_active($f); |
||||
} |
||||
redirect('?edit'); |
||||
} |
||||
|
||||
// ============================================================================= |
||||
// RENDERERS (GET handlers - output HTML, never redirect) |
||||
// ============================================================================= |
||||
|
||||
function render_login(): never { |
||||
$err = !empty($_GET['err']); ?> |
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head><meta charset="utf-8"><title>Login</title> |
||||
<style> |
||||
body{font:14px monospace;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#111;color:#eee} |
||||
form{display:flex;flex-direction:column;gap:8px;width:220px} |
||||
input[type=password]{padding:6px;background:#222;border:1px solid #555;color:#eee} |
||||
button{padding:6px;background:#444;border:none;color:#eee;cursor:pointer} |
||||
button:hover{background:#555} |
||||
.err{color:#f66;font-size:12px} |
||||
</style> |
||||
</head> |
||||
<body> |
||||
<form method="post" action="?login"> |
||||
<label>Password</label> |
||||
<input type="password" name="password" autofocus> |
||||
<button type="submit">Login</button> |
||||
<?php if ($err): ?><span class="err">Wrong password.</span><?php endif; ?> |
||||
</form> |
||||
</body></html> |
||||
<?php exit; } |
||||
|
||||
function render_edit(): never { |
||||
require_auth(); |
||||
$files = get_bin_files(); |
||||
$active = get_active(); ?> |
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head><meta charset="utf-8"><title>Select File</title> |
||||
<style> |
||||
body{font:14px monospace;background:#111;color:#eee;padding:24px;margin:0} |
||||
h2{margin:0 0 16px} |
||||
ul{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:6px} |
||||
li form{margin:0} |
||||
.row{display:flex;align-items:center;gap:10px} |
||||
button{padding:6px 12px;background:#333;border:1px solid #555;color:#eee;cursor:pointer;flex:1;text-align:left} |
||||
button:hover{background:#444} |
||||
button.active{border-color:#6af;color:#6af} |
||||
.thumb{width:480px;height:270px;object-fit:cover;border:1px solid #444;background:#222;flex-shrink:0} |
||||
.nothumb{width:64px;height:64px;flex-shrink:0} |
||||
.none{color:#888} |
||||
</style> |
||||
</head> |
||||
<body> |
||||
<h2>Live file selector</h2> |
||||
<p>Active: <strong><?= $active !== '' ? htmlspecialchars($active) : '<span class="none">none</span>' ?></strong></p>
|
||||
<?php if ($files): ?> |
||||
<ul> |
||||
<?php foreach ($files as $f): ?> |
||||
<li> |
||||
<form method="post" action="?edit"> |
||||
<div class="row"> |
||||
<?php if (has_png($f)): ?> |
||||
<img class="thumb" src="?img=<?= urlencode($f) ?>" alt="">
|
||||
<?php else: ?> |
||||
<div class="nothumb"></div> |
||||
<?php endif; ?> |
||||
<input type="hidden" name="file" value="<?= htmlspecialchars($f) ?>">
|
||||
<button type="submit"<?= $f === $active ? ' class="active"' : '' ?>><?= htmlspecialchars($f) ?></button>
|
||||
</div> |
||||
</form> |
||||
</li> |
||||
<?php endforeach; ?> |
||||
</ul> |
||||
<?php else: ?> |
||||
<p class="none">No .bin files found in avj2305/</p> |
||||
<?php endif; ?> |
||||
</body></html> |
||||
<?php exit; } |
||||
|
||||
// ============================================================================= |
||||
// FILE SERVER (default route) |
||||
// ============================================================================= |
||||
|
||||
function serve_png(): never { |
||||
require_auth(); |
||||
$f = $_GET['img'] ?? ''; |
||||
if (!safe_filename($f)) { |
||||
http_response_code(400); exit('Invalid filename.'); |
||||
} |
||||
$path = png_path($f); |
||||
if (!file_exists($path)) { |
||||
http_response_code(404); exit('No preview.'); |
||||
} |
||||
header('Content-Type: image/png'); |
||||
header('Content-Length: ' . filesize($path)); |
||||
readfile($path); |
||||
exit; |
||||
} |
||||
|
||||
function serve_active_file(): never { |
||||
$active = get_active(); |
||||
|
||||
if ($active === '') { |
||||
http_response_code(404); exit('No active file set.'); |
||||
} |
||||
if (!safe_filename($active)) { |
||||
http_response_code(500); exit('Invalid filename.'); |
||||
} |
||||
|
||||
$path = FILES_DIR . '/' . $active; |
||||
|
||||
if (!file_exists($path)) { |
||||
http_response_code(404); exit('File not found.'); |
||||
} |
||||
|
||||
header('Content-Type: application/octet-stream'); |
||||
header('Content-Disposition: inline; filename="' . addslashes($active) . '"'); |
||||
header('Content-Length: ' . filesize($path)); |
||||
readfile($path); |
||||
exit; |
||||
} |
||||
|
||||
|
||||
// ============================================================================= |
||||
// ROUTES |
||||
// ============================================================================= |
||||
// |
||||
// GET /live.php -> serve active .bin file |
||||
// GET /live.php?login -> login page |
||||
// POST /live.php?login -> process login |
||||
// GET /live.php?edit -> file picker [auth required] |
||||
// POST /live.php?edit -> set active file [auth required] |
||||
// GET /live.php?img=x.bin -> serve x.png preview [auth required] |
||||
// |
||||
// ============================================================================= |
||||
|
||||
$method = $_SERVER['REQUEST_METHOD']; |
||||
$route = array_key_first($_GET) ?? ''; |
||||
|
||||
match (true) { |
||||
$method === 'POST' && $route === 'login' => action_login(), |
||||
$method === 'POST' && $route === 'edit' => action_set_file(), |
||||
$method === 'GET' && $route === 'login' => render_login(), |
||||
$method === 'GET' && $route === 'edit' => render_edit(), |
||||
$method === 'GET' && $route === 'img' => serve_png(), |
||||
default => serve_active_file(), |
||||
}; |
||||
@ -1,68 +0,0 @@ |
||||
@media (max-width: 640px) { |
||||
#topbar { |
||||
overflow-x: auto; |
||||
overflow-y: hidden; |
||||
scrollbar-width: none; |
||||
-webkit-overflow-scrolling: touch; |
||||
gap: 6px; |
||||
padding: 0 8px; |
||||
} |
||||
|
||||
#topbar::-webkit-scrollbar { |
||||
display: none; |
||||
} |
||||
|
||||
#topbar>* { |
||||
flex-shrink: 0; |
||||
} |
||||
|
||||
#topbar .tb-btn { |
||||
font-size: 0; |
||||
gap: 0; |
||||
padding: 3px 8px; |
||||
} |
||||
|
||||
#topbar .tb-btn svg { |
||||
width: 15px; |
||||
height: 15px; |
||||
} |
||||
|
||||
#topbar #theme-toggle { |
||||
font-size: 11px; |
||||
} |
||||
|
||||
#topbar button[onclick="addSection()"] { |
||||
display: none; |
||||
} |
||||
|
||||
#mob-add-section { |
||||
display: flex; |
||||
} |
||||
|
||||
#topbar [style*="flex:1"] { |
||||
display: none; |
||||
} |
||||
|
||||
#main { |
||||
flex-direction: column; |
||||
overflow-y: auto; |
||||
} |
||||
|
||||
#left, |
||||
#right { |
||||
width: 100%; |
||||
border-right: none; |
||||
flex-shrink: 0; |
||||
} |
||||
|
||||
#left { |
||||
border-bottom: 1px solid var(--bd-inner); |
||||
padding: 10px; |
||||
gap: 8px; |
||||
} |
||||
|
||||
#display-box { |
||||
aspect-ratio: 2/1; |
||||
width: 100%; |
||||
} |
||||
} |
||||
@ -1,20 +0,0 @@ |
||||
{ |
||||
"name": "libmonoformat", |
||||
"module": "src/index.ts", |
||||
"type": "module", |
||||
"scripts": { |
||||
"build": "bun build src/browser.ts --target browser --outfile public/mono-display.js", |
||||
"test": "bun test" |
||||
}, |
||||
"devDependencies": { |
||||
"@types/bun": "latest" |
||||
}, |
||||
"peerDependencies": { |
||||
"typescript": "^5" |
||||
}, |
||||
"dependencies": { |
||||
"libmonoformat": "../ts", |
||||
"@types/mithril": "^2.2.8", |
||||
"mithril": "^2.3.8" |
||||
} |
||||
} |
||||
@ -1,952 +0,0 @@ |
||||
// browser.ts - Mithril entry point
|
||||
// Built by: bun build src/browser.ts --target browser --outfile public/mono-display.js
|
||||
|
||||
import m from "mithril"; |
||||
import { |
||||
ElementType, |
||||
ElementTypeToString, |
||||
MonoDisplayDriver, |
||||
MonoDisplayFile, |
||||
MonoDisplayParser, |
||||
MonoDisplayRenderer, |
||||
SectionType, |
||||
SectionTypeToString, |
||||
StringToElementType, |
||||
StringToSectionType, |
||||
cycleTheme, |
||||
type ElementTypeName, |
||||
type MonoFormatAnimation, |
||||
type MonoFormatElement, |
||||
type MonoFormatElementsAlways, |
||||
type MonoFormatElementsTimespan, |
||||
type MonoFormatImage2D, |
||||
type MonoFormatPixelImage, |
||||
type MonoFormatSection, |
||||
type SectionTypeName |
||||
} from "libmonoformat"; |
||||
|
||||
|
||||
let W = 120, H = 60; |
||||
|
||||
// --- State -------------------------------------------------------------------
|
||||
let file: MonoDisplayFile = new MonoDisplayFile([]); |
||||
let activeSecIndex: number | null = null; |
||||
let activeElIndex: number | null = null; |
||||
let currentFilename = "untitled"; |
||||
let isDirty = false; |
||||
|
||||
// --- Element metadata --------------------------------------------------------
|
||||
type ElFieldMeta = { key: string; label: string; type: string; default: any; full?: boolean }; |
||||
type ElFlagMeta = { key: string; label: string; default?: boolean }; |
||||
|
||||
const EL_FIELDS: Partial<Record<ElementTypeName, ElFieldMeta[]>> = { |
||||
Image2D: [ |
||||
{ key: "xOffset", label: "X offset", type: "number", default: 0 }, |
||||
{ key: "yOffset", label: "Y offset", type: "number", default: 0 }, |
||||
{ key: "width", label: "Width", type: "number", default: W }, |
||||
{ key: "height", label: "Height", type: "number", default: H }, |
||||
], |
||||
Animation: [ |
||||
{ key: "xOffset", label: "X offset", type: "number", default: 0 }, |
||||
{ key: "yOffset", label: "Y offset", type: "number", default: 0 }, |
||||
{ key: "width", label: "Width", type: "number", default: W }, |
||||
{ key: "height", label: "Height", type: "number", default: H }, |
||||
{ key: "updateInterval", label: "Update interval", type: "number", default: 12 }, |
||||
{ key: "frames", label: "Frames", type: "list", default: [] }, |
||||
], |
||||
ClippedText: [ |
||||
{ key: "text", label: "Text", type: "text", default: "Hello, World!", full: true }, |
||||
{ key: "xOffset", label: "X offset", type: "number", default: 0 }, |
||||
{ key: "yOffset", label: "Y offset", type: "number", default: 10 }, |
||||
{ key: "width", label: "Width", type: "number", default: 30 }, |
||||
{ key: "height", label: "Height", type: "number", default: 10 }, |
||||
{ key: "fontIndex", label: "Font", type: "font", default: 0 }, |
||||
], |
||||
HScrollText: [ |
||||
{ key: "text", label: "Text", type: "text", default: "Scrolling text - ", full: true }, |
||||
{ key: "xOffset", label: "X offset", type: "number", default: 0 }, |
||||
{ key: "yOffset", label: "Y offset", type: "number", default: 10 }, |
||||
{ key: "width", label: "Width", type: "number", default: 30 }, |
||||
{ key: "height", label: "Height", type: "number", default: 10 }, |
||||
{ key: "scrollSpeed", label: "Scroll speed", type: "number", default: 50 }, |
||||
{ key: "fontIndex", label: "Font", type: "font", default: 0 }, |
||||
], |
||||
VScrollText: [ |
||||
{ key: "text", label: "Text", type: "text", default: "Scrolling text - ", full: true }, |
||||
{ key: "xOffset", label: "X offset", type: "number", default: 0 }, |
||||
{ key: "yOffset", label: "Y offset", type: "number", default: 32 }, |
||||
{ key: "width", label: "Width", type: "number", default: 30 }, |
||||
{ key: "height", label: "Height", type: "number", default: 10 }, |
||||
{ key: "scrollSpeed", label: "Scroll speed", type: "number", default: 50 }, |
||||
{ key: "fontIndex", label: "Font", type: "font", default: 0 }, |
||||
], |
||||
Line: [ |
||||
{ key: "xOrigin", label: "X offset", type: "number", default: 0 }, |
||||
{ key: "yOrigin", label: "Y offset", type: "number", default: 32 }, |
||||
{ key: "xTarget", label: "X offset", type: "number", default: 0 }, |
||||
{ key: "yTarget", label: "Y offset", type: "number", default: 32 }, |
||||
{ key: "linestyle", label: "Lintylee S", type: "number", default: 0 }, |
||||
], |
||||
CurrentTime: [ |
||||
{ key: "xOffset", label: "X offset", type: "number", default: 0 }, |
||||
{ key: "yOffset", label: "Y offset", type: "number", default: 8 }, |
||||
{ key: "width", label: "Width", type: "number", default: W }, |
||||
{ key: "height", label: "Height", type: "number", default: H }, |
||||
{ key: "utcOffsetMinutes", label: "UTC offset (min)", type: "number", default: 120 }, |
||||
{ key: "fontIndex", label: "Font", type: "font", default: 0 }, |
||||
], |
||||
}; |
||||
|
||||
const EL_FLAGS: Partial<Record<ElementTypeName, ElFlagMeta[]>> = { |
||||
HScrollText: [ |
||||
{ key: "endless", label: "Endless" }, |
||||
{ key: "invertDirection", label: "Invert direction" }, |
||||
{ key: "padBefore", label: "Pad Before" }, |
||||
{ key: "padAfter", label: "Pad After" }, |
||||
], |
||||
CurrentTime: [ |
||||
{ key: "clock12h", label: "12h mode", default: false }, |
||||
{ key: "showHours", label: "Show hours", default: true }, |
||||
{ key: "showSeconds", label: "Show seconds", default: true }, |
||||
], |
||||
}; |
||||
|
||||
const EL_TYPES: ElementType[] = Object.keys(EL_FIELDS).map( |
||||
(x) => StringToElementType[x as ElementTypeName] |
||||
); |
||||
|
||||
const DEFAULT_FONTS = Object.keys(MonoDisplayRenderer.builtinFonts); |
||||
|
||||
// --- Confirm dialog ----------------------------------------------------------
|
||||
function showConfirm( |
||||
msg: string, |
||||
buttons: { label: string; primary?: boolean; action: boolean }[] |
||||
): Promise<boolean> { |
||||
return new Promise(resolve => { |
||||
const overlay = document.getElementById("confirm-overlay"); |
||||
const msgEl = document.getElementById("confirm-msg"); |
||||
const btnsEl = document.getElementById("confirm-btns"); |
||||
if (msgEl) msgEl.textContent = msg; |
||||
if (btnsEl) btnsEl.innerHTML = ""; |
||||
buttons.forEach(b => { |
||||
const btn = document.createElement("button"); |
||||
btn.className = "cb" + (b.primary ? " primary" : ""); |
||||
btn.textContent = b.label; |
||||
btn.onclick = () => { overlay?.classList.remove("show"); resolve(b.action); }; |
||||
btnsEl?.appendChild(btn); |
||||
}); |
||||
overlay?.classList.add("show"); |
||||
}); |
||||
} |
||||
|
||||
// --- Persistence -------------------------------------------------------------
|
||||
const STORAGE_KEY = "monodisplay_autosave"; |
||||
|
||||
function saveToStorage() { |
||||
try { |
||||
const buf = file.toBuffer(); |
||||
const b64 = btoa(String.fromCharCode(...buf)); |
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ filename: currentFilename, data: b64 })); |
||||
} catch { } |
||||
} |
||||
|
||||
function loadFromStorage() { |
||||
try { |
||||
const raw = localStorage.getItem(STORAGE_KEY); |
||||
if (!raw) return; |
||||
const { filename, data } = JSON.parse(raw); |
||||
const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0)); |
||||
const parsed = new MonoDisplayParser().parse(bytes.buffer); |
||||
file = new MonoDisplayFile(parsed.sections); |
||||
currentFilename = filename; |
||||
const filenameEl = document.getElementById("filename"); |
||||
if (filenameEl) filenameEl.textContent = filename; |
||||
isDirty = true; |
||||
document.getElementById("filename")?.classList.add("dirty"); |
||||
m.redraw(); |
||||
} catch { } |
||||
} |
||||
|
||||
// --- Dirty tracking ----------------------------------------------------------
|
||||
function markDirty() { |
||||
isDirty = true; |
||||
document.getElementById("filename")?.classList.add("dirty"); |
||||
saveToStorage(); |
||||
} |
||||
function markClean() { |
||||
isDirty = false; |
||||
document.getElementById("filename")?.classList.remove("dirty"); |
||||
} |
||||
async function guardDirty(): Promise<boolean> { |
||||
if (!isDirty || !file.sections.length) return true; |
||||
return showConfirm( |
||||
"You have unsaved changes. Loading a demo will replace the current editor state.", |
||||
[{ label: "Cancel", action: false }, { label: "Load anyway", primary: true, action: true }] |
||||
); |
||||
} |
||||
|
||||
function changeDisplayLayout() { |
||||
const sel = document.querySelector('.pv-label select') as HTMLSelectElement; |
||||
const [w, h] = sel.value.split('×').map(s => parseInt(s.trim())); |
||||
W = w as number; |
||||
H = h as number; |
||||
((window as any)._mdDriver as MonoDisplayDriver).setSize(W, H); |
||||
((window as any)._mdDriver as MonoDisplayDriver).load(() => Promise.resolve(file.toBuffer())); |
||||
} |
||||
|
||||
// --- Helpers -----------------------------------------------------------------
|
||||
function newSec(): MonoFormatElementsAlways { |
||||
return { |
||||
sectionType: SectionType.ElementsAlways, elements: [], flags: { |
||||
clearBuffer: true, |
||||
drawBack: true, |
||||
drawFront: true, |
||||
} |
||||
}; |
||||
} |
||||
|
||||
function createNewElement(type: ElementType): MonoFormatElement { |
||||
const fields: Record<string, any> = {}; |
||||
(EL_FIELDS[ElementTypeToString[type]] as ElFieldMeta[] || []) |
||||
.forEach(f => (fields[f.key] = f.default)); |
||||
const flags: Record<string, any> = {}; |
||||
(EL_FLAGS[ElementTypeToString[type]] as ElFlagMeta[] || []) |
||||
.forEach(f => (flags[f.key] = f.default ?? false)); |
||||
const el: any = { type, ...fields, flags }; |
||||
if (type === ElementType.Image2D) { |
||||
el.image = { pixels: new Uint8Array(el.width * el.height), width: el.width, height: el.height }; |
||||
} |
||||
if (type === ElementType.Animation) { |
||||
el.frames = [{ pixels: new Uint8Array(el.width * el.height), width: el.width, height: el.height }]; |
||||
} |
||||
return el as MonoFormatElement; |
||||
} |
||||
|
||||
function getSectionByIndex(i: number | null): MonoFormatSection | undefined { |
||||
return i !== null ? file.sections[i] : undefined; |
||||
} |
||||
|
||||
function getElementByIndex(si: number, ei: number): MonoFormatElement | undefined { |
||||
const s = getSectionByIndex(si); |
||||
return s && "elements" in s ? s.elements[ei] : undefined; |
||||
} |
||||
|
||||
function getCustomFonts(): { fontname: string; index: number }[] { |
||||
return file.sections |
||||
.filter(s => s.sectionType === SectionType.CustomFont) |
||||
.map((_, i) => ({ fontname: `CustomFont ${i}`, index: 0x8000 + i })); |
||||
} |
||||
|
||||
function elSummary(el: MonoFormatElement): string { |
||||
switch (el.type) { |
||||
case ElementType.ClippedText: |
||||
case ElementType.HScrollText: |
||||
return el.text.slice(0, 24) + (el.text.length > 24 ? "..." : ""); |
||||
case ElementType.CurrentTime: |
||||
case ElementType.Image2D: |
||||
case ElementType.HorizontalScroll: |
||||
case ElementType.VerticalScroll: |
||||
return `${(el as any).xOffset ?? 0}, ${(el as any).yOffset ?? 0}`; |
||||
default: |
||||
return el.type.toString(); |
||||
} |
||||
} |
||||
|
||||
// --- Mutations ----------------------------------------------------------------
|
||||
function addSection() { |
||||
file.sections.push(newSec()); |
||||
activeSecIndex = file.sections.length - 1; |
||||
activeElIndex = null; |
||||
markDirty(); triggerPreview(); m.redraw(); |
||||
} |
||||
|
||||
function removeSection(si: number) { |
||||
file.sections.splice(si, 1); |
||||
if (activeSecIndex === si) { |
||||
activeSecIndex = file.sections.length ? file.sections.length - 1 : null; |
||||
activeElIndex = null; |
||||
} |
||||
markDirty(); triggerPreview(); |
||||
} |
||||
|
||||
function toggleSection(si: number) { |
||||
activeSecIndex = si; |
||||
} |
||||
|
||||
function setSectionFlag(flag: string, val: boolean) { |
||||
if (activeSecIndex === null) return; |
||||
const s = getSectionByIndex(activeSecIndex); |
||||
if (!s) return; |
||||
(s as any).flags[flag] = val; |
||||
markDirty(); triggerPreview(); |
||||
} |
||||
|
||||
function setSectionField(si: number, key: string, val: any) { |
||||
const s = getSectionByIndex(si); |
||||
if (!s) return; |
||||
(s as any)[key] = val; |
||||
markDirty(); triggerPreview(); |
||||
} |
||||
|
||||
function setSectionType(si: number, sectionType: string) { |
||||
const sec = getSectionByIndex(si); |
||||
if (!sec) return; |
||||
const wasElementsSection = sec.sectionType !== SectionType.CustomFont; |
||||
(sec as any).sectionType = StringToSectionType[sectionType as SectionTypeName]; |
||||
if ((sec as any).sectionType === SectionType.CustomFont) { |
||||
(sec as any).fontData = new Uint8Array(); |
||||
delete (sec as any).elements; |
||||
delete (sec as any).flags; |
||||
} else { |
||||
if (!wasElementsSection) { |
||||
(sec as any).elements = []; |
||||
(sec as any).flags = {}; |
||||
} |
||||
delete (sec as any).fontData; |
||||
if ((sec as any).sectionType === SectionType.ElementsTimespan) { |
||||
const now = BigInt(Math.floor(Date.now() / 1000)); |
||||
(sec as any).startTimestamp = now; |
||||
(sec as any).endTimestamp = now + 3600n; |
||||
} else { |
||||
delete (sec as any).startTimestamp; |
||||
delete (sec as any).endTimestamp; |
||||
} |
||||
} |
||||
markDirty(); triggerPreview(); |
||||
} |
||||
|
||||
function addElement(si: number) { |
||||
const s = getSectionByIndex(si); |
||||
if (!s || !("elements" in s)) return; |
||||
s.elements.push(createNewElement(ElementType.HScrollText)); |
||||
activeElIndex = s.elements.length - 1; |
||||
activeSecIndex = si; |
||||
markDirty(); triggerPreview(); |
||||
} |
||||
|
||||
function removeElement(si: number, ei: number) { |
||||
const s = getSectionByIndex(si); |
||||
if (!s || !("elements" in s)) return; |
||||
s.elements.splice(ei, 1); |
||||
activeElIndex = null; |
||||
markDirty(); triggerPreview(); |
||||
} |
||||
|
||||
function selectElement(si: number, ei: number) { |
||||
if (activeSecIndex === si && activeElIndex === ei) { |
||||
activeElIndex = null; |
||||
} else { |
||||
activeSecIndex = si; |
||||
activeElIndex = ei; |
||||
} |
||||
} |
||||
|
||||
function changeElType(si: number, ei: number, typeString: ElementTypeName) { |
||||
const el = getElementByIndex(si, ei); |
||||
if (!el) return; |
||||
const fresh = createNewElement(StringToElementType[typeString]); |
||||
Object.assign(el, fresh); |
||||
markDirty(); triggerPreview(); |
||||
} |
||||
|
||||
function setElField(si: number, ei: number, key: string, val: any) { |
||||
const el = getElementByIndex(si, ei); |
||||
if (!el) return; |
||||
(el as any)[key] = val; |
||||
markDirty(); triggerPreview(); |
||||
} |
||||
|
||||
function setElFlag(si: number, ei: number, key: string, val: any) { |
||||
const el = getElementByIndex(si, ei); |
||||
if (!el) return; |
||||
(el as any).flags[key] = val; |
||||
markDirty(); triggerPreview(); |
||||
} |
||||
|
||||
// --- DEMO --------------------------------------------------------------------
|
||||
const DEMO: Record<string, () => MonoDisplayFile> = { |
||||
Checkerboard() { |
||||
const pixels = new Uint8Array(W * H); |
||||
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) pixels[y * W + x] = (x + y) % 2; |
||||
return new MonoDisplayFile([{ |
||||
sectionType: SectionType.ElementsAlways, |
||||
elements: [{ type: ElementType.Image2D, image: { pixels, height: H, width: W }, xOffset: 0, yOffset: 0 }], |
||||
flags: { clearBuffer: true, drawFront: true, drawBack: true }, |
||||
}]); |
||||
}, |
||||
Blink() { |
||||
return new MonoDisplayFile([{ |
||||
sectionType: SectionType.ElementsAlways, |
||||
elements: [{ |
||||
type: ElementType.Animation, width: W, height: H, updateInterval: 12, xOffset: 0, yOffset: 0, |
||||
frames: [ |
||||
{ pixels: new Uint8Array(W * H).fill(1), width: 0, height: 0 }, |
||||
{ pixels: new Uint8Array(W * H).fill(0), width: 0, height: 0 }, |
||||
], |
||||
}], |
||||
flags: { clearBuffer: true, drawFront: true, drawBack: true }, |
||||
}]); |
||||
}, |
||||
Text() { |
||||
return new MonoDisplayFile([{ |
||||
sectionType: SectionType.ElementsAlways, |
||||
elements: [{ type: ElementType.ClippedText, fontIndex: 0, text: "Hello, World!", xOffset: 0, yOffset: 16, width: 60, height: 10 }], |
||||
flags: { clearBuffer: true, drawFront: true, drawBack: true }, |
||||
}]); |
||||
}, |
||||
Scrolltext() { |
||||
return new MonoDisplayFile([{ |
||||
sectionType: SectionType.ElementsAlways, |
||||
elements: [{ |
||||
type: ElementType.HScrollText, fontIndex: 0, |
||||
text: "MONO DISPLAY - scrolling ticker - 🚀 ", |
||||
xOffset: 0, yOffset: 32, width: W, height: 16, scrollSpeed: 50, |
||||
flags: { endless: true, invertDirection: false, padStart: false, padEnd: false }, |
||||
}], |
||||
flags: { clearBuffer: true, drawFront: true, drawBack: true }, |
||||
}]); |
||||
}, |
||||
Time() { |
||||
return new MonoDisplayFile([{ |
||||
sectionType: SectionType.ElementsAlways, |
||||
elements: [ |
||||
{ type: ElementType.CurrentTime, fontIndex: 0, flags: {}, xOffset: 0, yOffset: 8, width: W, height: 16, utcOffsetMinutes: 120 }, |
||||
{ type: ElementType.CurrentTime, fontIndex: 0, flags: { clock12h: true }, xOffset: 40, yOffset: 16, width: W, height: 16, utcOffsetMinutes: 120 }, |
||||
{ type: ElementType.CurrentTime, fontIndex: 0, flags: { clock12h: true, showHours: true }, xOffset: 0, yOffset: 24, width: W, height: 16, utcOffsetMinutes: 120 }, |
||||
{ type: ElementType.CurrentTime, fontIndex: 0, flags: { clock12h: true, showHours: false }, xOffset: 40, yOffset: 32, width: W, height: 16, utcOffsetMinutes: 120 }, |
||||
{ type: ElementType.CurrentTime, fontIndex: 0, flags: { clock12h: true, showSeconds: true }, xOffset: 80, yOffset: 32, width: W, height: 16, utcOffsetMinutes: 120 }, |
||||
], |
||||
flags: { clearBuffer: true, drawFront: true, drawBack: true }, |
||||
}]); |
||||
}, |
||||
}; |
||||
|
||||
// --- Preview -----------------------------------------------------------------
|
||||
let previewTimer: ReturnType<typeof setTimeout> | null = null; |
||||
|
||||
function triggerPreview() { |
||||
if (previewTimer) clearTimeout(previewTimer); |
||||
previewTimer = setTimeout(() => { |
||||
try { buildPreview(); } catch (e) { console.warn("preview", e); } |
||||
}, 150); |
||||
} |
||||
|
||||
function buildPreview() { |
||||
if (!(window as any)._mdDriver) |
||||
(window as any)._mdDriver = new MonoDisplayDriver("canvas_root", { onColor: "#EC0", offColor: "#000", fps: 25 }); |
||||
changeDisplayLayout(); |
||||
} |
||||
|
||||
// --- Load / Export -----------------------------------------------------------
|
||||
function loadBin(input: HTMLInputElement) { |
||||
const binFile = input.files?.[0]; |
||||
if (!binFile) return; |
||||
currentFilename = binFile.name; |
||||
const filenameEl = document.getElementById("filename"); |
||||
if (filenameEl) filenameEl.textContent = binFile.name; |
||||
const reader = new FileReader(); |
||||
reader.onload = e => { |
||||
try { |
||||
const parsed = new MonoDisplayParser().parse(e.target!.result as ArrayBuffer); |
||||
file = new MonoDisplayFile(parsed.sections); |
||||
activeSecIndex = null; |
||||
activeElIndex = null; |
||||
triggerPreview(); |
||||
m.redraw(); |
||||
} catch (err) { |
||||
console.warn("Could not parse sections from bin:", err); |
||||
} |
||||
}; |
||||
reader.readAsArrayBuffer(binFile); |
||||
input.value = ""; |
||||
markClean(); |
||||
} |
||||
|
||||
function exportBin() { |
||||
// if (!window.MonoDisplay) { alert("MonoDisplay library not loaded."); return; }
|
||||
const blob = new Blob([file.toBuffer() as any], { type: "application/octet-stream" }); |
||||
const a = document.createElement("a"); |
||||
a.href = URL.createObjectURL(blob); |
||||
a.download = currentFilename.replace(/\.bin$/, "") + ".bin"; |
||||
a.click(); |
||||
markClean(); |
||||
} |
||||
|
||||
// --- Mithril Components -------------------------------------------------------
|
||||
|
||||
function FieldInput(si: number, ei: number, field: ElFieldMeta, el: MonoFormatElement): m.Vnode { |
||||
const val = (el as any)[field.key] ?? field.default; |
||||
switch (field.type) { |
||||
case "text": |
||||
return m("textarea", { |
||||
onchange: (e: Event) => setElField(si, ei, field.key, (e.target as HTMLTextAreaElement).value), |
||||
}, val); |
||||
case "font": |
||||
return m("select", { |
||||
onchange: (e: Event) => setElField(si, ei, field.key, +(e.target as HTMLSelectElement).value), |
||||
}, [ |
||||
...DEFAULT_FONTS.map((name, i) => m("option", { value: i, selected: val === i }, name)), |
||||
...getCustomFonts().map(cf => m("option", { value: cf.index, selected: val === cf.index }, cf.fontname)), |
||||
]); |
||||
case "list": |
||||
return m("span"); // rendered by dedicated editor
|
||||
default: |
||||
return m("input[type=number]", { |
||||
value: val, |
||||
onchange: (e: Event) => setElField(si, ei, field.key, +(e.target as HTMLInputElement).value), |
||||
}); |
||||
} |
||||
} |
||||
|
||||
// --- Shared pixel canvas ------------------------------------------------------
|
||||
|
||||
const PIXEL_SCALE = 4; |
||||
|
||||
function getPixelIndex(img: MonoFormatPixelImage, x: number, y: number): number { |
||||
return y * img.width + x; |
||||
} |
||||
|
||||
function drawPixelCanvas(canvas: HTMLCanvasElement, img: MonoFormatPixelImage) { |
||||
const ctx = canvas.getContext("2d")!; |
||||
ctx.fillStyle = "#000"; |
||||
ctx.fillRect(0, 0, canvas.width, canvas.height); |
||||
ctx.fillStyle = "#EC0"; |
||||
for (let y = 0; y < img.height; y++) { |
||||
for (let x = 0; x < img.width; x++) { |
||||
if (img.pixels[getPixelIndex(img, x, y)]) { |
||||
ctx.fillRect(x * PIXEL_SCALE, y * PIXEL_SCALE, PIXEL_SCALE, PIXEL_SCALE); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
interface PixelCanvasState { drawing: boolean; drawValue: number; } |
||||
|
||||
const PixelCanvas: m.Component<{ img: MonoFormatPixelImage; onpaint: () => void }, PixelCanvasState> = { |
||||
oninit(vnode) { |
||||
vnode.state.drawing = false; |
||||
vnode.state.drawValue = 1; |
||||
}, |
||||
view({ attrs: { img, onpaint }, state: s }) { |
||||
function pixelFromCoords(canvas: HTMLCanvasElement, clientX: number, clientY: number) { |
||||
const rect = canvas.getBoundingClientRect(); |
||||
const scaleX = canvas.width / rect.width; |
||||
const scaleY = canvas.height / rect.height; |
||||
const x = Math.floor((clientX - rect.left) * scaleX / PIXEL_SCALE); |
||||
const y = Math.floor((clientY - rect.top) * scaleY / PIXEL_SCALE); |
||||
if (x < 0 || x >= img.width || y < 0 || y >= img.height) return null; |
||||
return { x, y }; |
||||
} |
||||
|
||||
function paintAt(canvas: HTMLCanvasElement, clientX: number, clientY: number) { |
||||
const p = pixelFromCoords(canvas, clientX, clientY); |
||||
if (!p) return; |
||||
img.pixels[getPixelIndex(img, p.x, p.y)] = s.drawValue; |
||||
onpaint(); |
||||
drawPixelCanvas(canvas, img); |
||||
} |
||||
|
||||
function paint(e: MouseEvent) { |
||||
paintAt(e.currentTarget as HTMLCanvasElement, e.clientX, e.clientY); |
||||
} |
||||
|
||||
function attachTouch(canvas: HTMLCanvasElement) { |
||||
canvas.addEventListener("touchstart", (e: TouchEvent) => { |
||||
e.preventDefault(); |
||||
s.drawing = true; |
||||
const touch = e.changedTouches[0]; |
||||
const p = pixelFromCoords(canvas, touch.clientX, touch.clientY); |
||||
if (p) s.drawValue = img.pixels[getPixelIndex(img, p.x, p.y)] ? 0 : 1; |
||||
paintAt(canvas, touch.clientX, touch.clientY); |
||||
}, { passive: false }); |
||||
|
||||
canvas.addEventListener("touchmove", (e: TouchEvent) => { |
||||
e.preventDefault(); |
||||
if (!s.drawing) return; |
||||
const touch = e.changedTouches[0]; |
||||
paintAt(canvas, touch.clientX, touch.clientY); |
||||
}, { passive: false }); |
||||
|
||||
canvas.addEventListener("touchend", (e) => { e.preventDefault(); s.drawing = false; }, { passive: false }); |
||||
canvas.addEventListener("touchcancel", (e) => { e.preventDefault(); s.drawing = false; }, { passive: false }); |
||||
} |
||||
|
||||
return m("canvas.pixel-editor", { |
||||
width: img.width * PIXEL_SCALE, |
||||
height: img.height * PIXEL_SCALE, |
||||
oncreate: ({ dom }: m.VnodeDOM) => { |
||||
const canvas = dom as HTMLCanvasElement; |
||||
drawPixelCanvas(canvas, img); |
||||
attachTouch(canvas); |
||||
}, |
||||
onupdate: ({ dom }: m.VnodeDOM) => drawPixelCanvas(dom as HTMLCanvasElement, img), |
||||
|
||||
onmousedown: (e: MouseEvent) => { |
||||
s.drawing = true; |
||||
const p = pixelFromCoords(e.currentTarget as HTMLCanvasElement, e.clientX, e.clientY); |
||||
if (p) s.drawValue = img.pixels[getPixelIndex(img, p.x, p.y)] ? 0 : 1; |
||||
paint(e); |
||||
}, |
||||
onmousemove: (e: MouseEvent) => { if (s.drawing) paint(e); }, |
||||
onmouseup: () => { s.drawing = false; }, |
||||
onmouseleave: () => { s.drawing = false; }, |
||||
|
||||
ondragover: (e: DragEvent) => { |
||||
e.preventDefault(); |
||||
e.dataTransfer!.dropEffect = "copy"; |
||||
}, |
||||
ondrop: (e: DragEvent) => { |
||||
e.preventDefault(); |
||||
const file = e.dataTransfer?.files[0]; |
||||
if (!file || !file.type.startsWith("image/")) return; |
||||
const url = URL.createObjectURL(file); |
||||
const htmlImg = new Image(); |
||||
htmlImg.onload = () => { |
||||
const offscreen = document.createElement("canvas"); |
||||
const ctx = offscreen.getContext("2d")!; |
||||
const scale = Math.min(1, 120 / htmlImg.width, 60 / htmlImg.height); |
||||
const w = Math.round(htmlImg.width * scale); |
||||
const h = Math.round(htmlImg.height * scale); |
||||
offscreen.width = w; |
||||
offscreen.height = h; |
||||
ctx.drawImage(htmlImg, 0, 0, w, h); |
||||
const imageData = ctx.getImageData(0, 0, w, h); |
||||
img.width = w; |
||||
img.height = h; |
||||
img.pixels = new Uint8Array(w * h).map((_, i) => { |
||||
const r = imageData.data[i * 4]; |
||||
const g = imageData.data[i * 4 + 1]; |
||||
const b = imageData.data[i * 4 + 2]; |
||||
const a = imageData.data[i * 4 + 3]; |
||||
return a > 128 && (r * 299 + g * 587 + b * 114) < 128_000 ? 0 : 1; |
||||
}); |
||||
URL.revokeObjectURL(url); |
||||
m.redraw(); |
||||
}; |
||||
htmlImg.src = url; |
||||
}, |
||||
}); |
||||
}, |
||||
}; |
||||
|
||||
// --- Image2D editor -----------------------------------------------------------
|
||||
|
||||
const Image2DEditor: m.Component<{ si: number; ei: number; el: MonoFormatImage2D }> = { |
||||
view({ attrs: { si, ei, el } }) { |
||||
if (!el.image) { |
||||
const w = (el as any).width || W; |
||||
const h = (el as any).height || H; |
||||
el.image = { pixels: new Uint8Array(w * h), width: w, height: h }; |
||||
} |
||||
return m(PixelCanvas, { |
||||
img: el.image, |
||||
onpaint: () => { markDirty(); triggerPreview(); }, |
||||
}); |
||||
}, |
||||
}; |
||||
|
||||
// --- Animation editor ---------------------------------------------------------
|
||||
|
||||
interface AnimationEditorState { activeFrame: number; } |
||||
|
||||
const AnimationEditor: m.Component<{ si: number; ei: number; el: MonoFormatAnimation }, AnimationEditorState> = { |
||||
oninit(vnode) { |
||||
vnode.state.activeFrame = 0; |
||||
}, |
||||
view({ attrs: { si, ei, el }, state }) { |
||||
const w = el.width || W; |
||||
const h = el.height || H; |
||||
if (!el.frames) el.frames = []; |
||||
if (!el.frames.length) { |
||||
el.frames.push({ pixels: new Uint8Array(w * h), width: w, height: h }); |
||||
} |
||||
state.activeFrame = Math.min(state.activeFrame, el.frames.length - 1); |
||||
const frame = el.frames[state.activeFrame]; |
||||
|
||||
function addFrame() { |
||||
el.frames.push({ pixels: new Uint8Array(w * h), width: w, height: h }); |
||||
state.activeFrame = el.frames.length - 1; |
||||
markDirty(); triggerPreview(); m.redraw(); |
||||
} |
||||
|
||||
function removeFrame(fi: number) { |
||||
el.frames.splice(fi, 1); |
||||
if (!el.frames.length) |
||||
el.frames.push({ pixels: new Uint8Array(w * h), width: w, height: h }); |
||||
state.activeFrame = Math.min(state.activeFrame, el.frames.length - 1); |
||||
markDirty(); triggerPreview(); |
||||
} |
||||
|
||||
return m(".anim-editor", |
||||
m(".anim-frame-tabs", { key: "tabs" }, |
||||
el.frames.map((_, fi) => |
||||
m(".anim-frame-tab", { |
||||
class: fi === state.activeFrame ? "active" : "", |
||||
onclick: () => { state.activeFrame = fi; }, |
||||
}, |
||||
m("span", fi + 1), |
||||
el.frames.length > 1 |
||||
? m("button.x-btn-sm", { |
||||
onclick: (e: Event) => { e.stopPropagation(); removeFrame(fi); }, |
||||
}, "×") |
||||
: null, |
||||
) |
||||
), |
||||
m("button.add-el", { onclick: addFrame }, "+ frame"), |
||||
), |
||||
m("div", { key: state.activeFrame }, |
||||
m(PixelCanvas as any, { |
||||
img: frame, |
||||
onpaint: () => { markDirty(); triggerPreview(); }, |
||||
}), |
||||
), |
||||
); |
||||
}, |
||||
}; |
||||
|
||||
const ElementItem: m.Component<{ si: number; ei: number; el: MonoFormatElement }> = { |
||||
view({ attrs: { si, ei, el } }) { |
||||
const isActive = activeSecIndex === si && activeElIndex === ei; |
||||
const typeStr = ElementTypeToString[el.type]; |
||||
const fields = EL_FIELDS[typeStr] || []; |
||||
const flags = EL_FLAGS[typeStr] || []; |
||||
|
||||
return m(".el-item", { class: isActive ? "active" : "" }, |
||||
m(".el-item-hdr", { onclick: () => selectElement(si, ei) }, |
||||
m("span.el-type", typeStr), |
||||
m("span.el-name", elSummary(el)), |
||||
m("button.x-btn", { |
||||
title: "Remove element", |
||||
onclick: (e: Event) => { e.stopPropagation(); removeElement(si, ei); }, |
||||
}, "X"), |
||||
), |
||||
isActive && m(".el-fields", |
||||
m(".field.full", |
||||
m("label", "Type"), |
||||
m("select", { |
||||
onchange: (e: Event) => changeElType(si, ei, (e.target as HTMLSelectElement).value as ElementTypeName), |
||||
}, |
||||
EL_TYPES.map(t => |
||||
m("option", { value: ElementTypeToString[t], selected: t === el.type }, ElementTypeToString[t]) |
||||
) |
||||
), |
||||
), |
||||
m(".fields-grid", |
||||
fields.map(field => |
||||
m(`.field${field.full ? ".full" : ""}`, |
||||
m("label", field.label), |
||||
FieldInput(si, ei, field, el), |
||||
) |
||||
) |
||||
), |
||||
flags.length ? m(".field.full", |
||||
m("label", "Flags"), |
||||
m(".flags-row", |
||||
flags.map(flag => |
||||
m("label.flag-check", |
||||
m("input[type=checkbox]", { |
||||
checked: !!(el as any).flags?.[flag.key], |
||||
onchange: (e: Event) => setElFlag(si, ei, flag.key, (e.target as HTMLInputElement).checked), |
||||
}), |
||||
" " + flag.label, |
||||
) |
||||
) |
||||
), |
||||
) : null, |
||||
el.type === ElementType.Image2D |
||||
? m(".field.full", |
||||
m("label", "Pixels"), |
||||
m(Image2DEditor, { si, ei, el: el as MonoFormatImage2D }), |
||||
) |
||||
: el.type === ElementType.Animation |
||||
? m(".field.full", |
||||
m("label", "Frames"), |
||||
m(AnimationEditor, { si, ei, el: el as MonoFormatAnimation }), |
||||
) |
||||
: null, |
||||
), |
||||
); |
||||
}, |
||||
}; |
||||
|
||||
const SectionCard: m.Component<{ si: number }> = { |
||||
view({ attrs: { si } }) { |
||||
const section = file.sections[si]; |
||||
if (!section) return null; |
||||
const isActive = activeSecIndex === si; |
||||
const typeStr = SectionTypeToString[section.sectionType]; |
||||
|
||||
const hdr = m(`.sec-hdr${isActive ? ".active" : ""}`, { onclick: () => toggleSection(si) }, |
||||
m("span.sec-arrow", "▶"), |
||||
m("span.sec-label", typeStr), |
||||
m("button.x-btn", { |
||||
title: "Remove section", |
||||
onclick: (e: Event) => { e.stopPropagation(); removeSection(si); }, |
||||
}, "X"), |
||||
); |
||||
|
||||
if (section.sectionType === SectionType.CustomFont) { |
||||
return m(`.sec-card.open${isActive ? ".active" : ""}`, |
||||
m(`.sec-hdr${isActive ? ".active" : ""}`, { onclick: () => toggleSection(si) }, |
||||
m("span.sec-arrow", "▶"), |
||||
m("span.sec-label", "Custom Font"), |
||||
m("span.sec-badge", "1 el"), |
||||
m("button.x-btn", { |
||||
title: "Remove section", |
||||
onclick: (e: Event) => { e.stopPropagation(); removeSection(si); }, |
||||
}, "X"), |
||||
), |
||||
m(".el-list", "?"), |
||||
); |
||||
} |
||||
|
||||
const elSection = section as MonoFormatElementsAlways | MonoFormatElementsTimespan; |
||||
|
||||
return m(`.sec-card.open${isActive ? ".active" : ""}`, |
||||
m(`.sec-hdr${isActive ? ".active" : ""}`, { onclick: () => toggleSection(si) }, |
||||
m("span.sec-arrow", "▶"), |
||||
m("span.sec-label", typeStr), |
||||
m("span.sec-badge", `${elSection.elements.length} el`), |
||||
m("button.x-btn", { |
||||
title: "Remove section", |
||||
onclick: (e: Event) => { e.stopPropagation(); removeSection(si); }, |
||||
}, "X"), |
||||
), |
||||
section.sectionType === SectionType.ElementsTimespan |
||||
? m(".el-list", |
||||
([["Start Time", "startTimestamp"], ["Stop Time", "endTimestamp"]] as [string, string][]) |
||||
.map(([label, key]) => { |
||||
let posix: bigint = (section as any)[key]; |
||||
if (!posix) { |
||||
posix = BigInt(Math.floor(Date.now() / 1000)); |
||||
setSectionField(si, key, posix); |
||||
} |
||||
const dtLocal = new Date(Number(posix) * 1000).toISOString().slice(0, 16); |
||||
return m(".field", |
||||
m("label", label), |
||||
m("input[type=datetime-local]", { |
||||
value: dtLocal, |
||||
onchange: (e: Event) => { |
||||
const ms = new Date((e.target as HTMLInputElement).value).getTime(); |
||||
setSectionField(si, key, BigInt(Math.floor(ms / 1000))); |
||||
}, |
||||
}), |
||||
); |
||||
}) |
||||
) |
||||
: null, |
||||
m(".el-list", |
||||
elSection.elements.map((el, ei) => m(ElementItem, { key: String(ei), si, ei, el })), |
||||
m("button.add-el", { onclick: () => addElement(si) }, "+ add element"), |
||||
), |
||||
); |
||||
}, |
||||
}; |
||||
|
||||
const SectionsList: m.Component = { |
||||
view() { |
||||
if (!file.sections.length) |
||||
return m(".empty-state", m.trust("No sections yet.<br/>Use <b>Add section</b> to get started.")); |
||||
if (activeSecIndex === null && file.sections.length === 1) activeSecIndex = 0; |
||||
return m("", file.sections.map((_, si) => m(SectionCard, { key: String(si), si }))); |
||||
}, |
||||
}; |
||||
|
||||
const MetaPanel: m.Component = { |
||||
view() { |
||||
const section = getSectionByIndex(activeSecIndex); |
||||
return m("", |
||||
m(".meta-row", |
||||
m("label", "Selected section"), |
||||
section |
||||
? m("select.meta-val.green", { |
||||
value: SectionTypeToString[section.sectionType], |
||||
onchange: (e: Event) => |
||||
activeSecIndex !== null && setSectionType(activeSecIndex, (e.target as HTMLSelectElement).value), |
||||
}, |
||||
Object.values(SectionTypeToString).map(name => |
||||
m("option", { value: name, selected: name === SectionTypeToString[section.sectionType] }, name) |
||||
) |
||||
) |
||||
: m("span.meta-val.green", "-"), |
||||
), |
||||
m(".meta-row", |
||||
m("label", "Elements"), |
||||
m("span.meta-val", section && "elements" in section ? `${section.elements.length} element(s)` : "-"), |
||||
), |
||||
m(".meta-row", |
||||
m("label", "Section flags"), |
||||
(["drawFront", "drawBack", "clearBuffer"] as const).map((flag, i) => |
||||
m("label.flag-check", { style: i > 0 ? "margin-left:-8px" : "" }, |
||||
m("input[type=checkbox]", { |
||||
checked: section ? !!(section as any).flags?.[flag] : false, |
||||
onchange: (e: Event) => setSectionFlag(flag, (e.target as HTMLInputElement).checked), |
||||
}), |
||||
" " + flag, |
||||
) |
||||
), |
||||
), |
||||
); |
||||
}, |
||||
}; |
||||
|
||||
let activeDemoName: string | null = null; |
||||
|
||||
const DemoButtons: m.Component = { |
||||
view() { |
||||
return Object.entries(DEMO).map(([name, fn]) => |
||||
m("button.tb-btn", { |
||||
style: activeDemoName === name ? "color:#33ff66" : "", |
||||
onclick: async () => { |
||||
const ok = await guardDirty(); |
||||
if (!ok) return; |
||||
file = fn(); |
||||
activeSecIndex = null; |
||||
activeElIndex = null; |
||||
currentFilename = name.toLowerCase(); |
||||
const filenameEl = document.getElementById("filename"); |
||||
if (filenameEl) filenameEl.textContent = currentFilename; |
||||
activeDemoName = name; |
||||
markClean(); |
||||
triggerPreview(); |
||||
m.redraw(); |
||||
}, |
||||
}, name) |
||||
); |
||||
}, |
||||
}; |
||||
|
||||
// --- Mount --------------------------------------------------------------------
|
||||
m.mount(document.getElementById("sections-wrap")!, SectionsList); |
||||
m.mount(document.getElementById("demo-btns")!, DemoButtons); |
||||
m.mount(document.getElementById("sec-meta")!, MetaPanel); |
||||
|
||||
async function clearAll() { |
||||
if (isDirty && file.sections.length) { |
||||
const ok = await showConfirm("Clear all sections?", [ |
||||
{ label: "Cancel", action: false }, |
||||
{ label: "Clear", primary: true, action: true }, |
||||
]); |
||||
if (!ok) return; |
||||
} |
||||
file = new MonoDisplayFile([]); |
||||
activeSecIndex = null; |
||||
activeElIndex = null; |
||||
currentFilename = "untitled"; |
||||
const filenameEl = document.getElementById("filename"); |
||||
if (filenameEl) filenameEl.textContent = "untitled"; |
||||
localStorage.removeItem(STORAGE_KEY); |
||||
markClean(); |
||||
triggerPreview(); |
||||
m.redraw(); |
||||
} |
||||
|
||||
// Expose globals referenced by topbar inline handlers
|
||||
Object.assign(window, { loadBin, exportBin, addSection, clearAll, cycleTheme, changeDisplayLayout }); |
||||
|
||||
// window.addEventListener("beforeunload", (e) => {
|
||||
// if (isDirty) e.preventDefault();
|
||||
// });
|
||||
|
||||
loadFromStorage(); |
||||
triggerPreview(); |
||||
@ -1,713 +0,0 @@ |
||||
:root { |
||||
--bg: #111; |
||||
--bg-bar: #1a1a1a; |
||||
--bg-raised: #161616; |
||||
--bg-hover: #1b1b1b; |
||||
--bg-hover2: #222; |
||||
--bg-sunken: #141414; |
||||
--bg-accent: #141e14; |
||||
--bg-active: #1e2e1e; |
||||
--bg-deep: #0f0f0f; |
||||
--bg-input: #0a0a0a; |
||||
--bg-canvas: #000; |
||||
--bg-xhover: #1e1010; |
||||
--bg-overlay: rgba(0, 0, 0, .6); |
||||
--bd: #2a2a2a; |
||||
--bd-inner: #1e1e1e; |
||||
--bd-deep: #1c1c1c; |
||||
--bd-card: #222; |
||||
--bd-btn: #2d2d2d; |
||||
--bd-type: #252525; |
||||
--bd-active: #2b3d2b; |
||||
--bd-hover: #444; |
||||
--bd-strong: #333; |
||||
--tx: #ccc; |
||||
--tx-hover: #bbb; |
||||
--tx-sub: #888; |
||||
--tx-label: #777; |
||||
--tx-meta: #666; |
||||
--tx-dim: #555; |
||||
--tx-faint: #444; |
||||
--tx-ghost: #333; |
||||
--tx-file: #3a3a3a; |
||||
--tx-empty: #2e2e2e; |
||||
--ac: #33ff66; |
||||
--dirty: #886622 |
||||
} |
||||
|
||||
@media (prefers-color-scheme: light) { |
||||
:root:not([data-theme="dark"]) { |
||||
--bg: #f5f5f5; |
||||
--bg-bar: #e8e8e8; |
||||
--bg-raised: #efefef; |
||||
--bg-hover: #e5e5e5; |
||||
--bg-hover2: #e0e0e0; |
||||
--bg-sunken: #ebebeb; |
||||
--bg-accent: #e8f5e8; |
||||
--bg-active: #dff0df; |
||||
--bg-deep: #f0f0f0; |
||||
--bg-input: #fff; |
||||
--bg-canvas: #fff; |
||||
--bg-xhover: #ffe8e8; |
||||
--bg-overlay: rgba(0, 0, 0, .4); |
||||
--bd: #d0d0d0; |
||||
--bd-inner: #ddd; |
||||
--bd-deep: #e0e0e0; |
||||
--bd-card: #ddd; |
||||
--bd-btn: #ccc; |
||||
--bd-type: #d8d8d8; |
||||
--bd-active: #7dc87d; |
||||
--bd-hover: #bbb; |
||||
--bd-strong: #ccc; |
||||
--tx: #222; |
||||
--tx-hover: #333; |
||||
--tx-sub: #555; |
||||
--tx-label: #666; |
||||
--tx-meta: #777; |
||||
--tx-dim: #888; |
||||
--tx-faint: #999; |
||||
--tx-ghost: #aaa; |
||||
--tx-file: #aaa; |
||||
--tx-empty: #bbb; |
||||
--ac: #1a9940; |
||||
--dirty: #b37a00 |
||||
} |
||||
} |
||||
|
||||
[data-theme="light"] { |
||||
--bg: #f5f5f5; |
||||
--bg-bar: #e8e8e8; |
||||
--bg-raised: #efefef; |
||||
--bg-hover: #e5e5e5; |
||||
--bg-hover2: #e0e0e0; |
||||
--bg-sunken: #ebebeb; |
||||
--bg-accent: #e8f5e8; |
||||
--bg-active: #dff0df; |
||||
--bg-deep: #f0f0f0; |
||||
--bg-input: #fff; |
||||
--bg-canvas: #fff; |
||||
--bg-xhover: #ffe8e8; |
||||
--bg-overlay: rgba(0, 0, 0, .4); |
||||
--bd: #d0d0d0; |
||||
--bd-inner: #ddd; |
||||
--bd-deep: #e0e0e0; |
||||
--bd-card: #ddd; |
||||
--bd-btn: #ccc; |
||||
--bd-type: #d8d8d8; |
||||
--bd-active: #7dc87d; |
||||
--bd-hover: #bbb; |
||||
--bd-strong: #ccc; |
||||
--tx: #222; |
||||
--tx-hover: #333; |
||||
--tx-sub: #555; |
||||
--tx-label: #666; |
||||
--tx-meta: #777; |
||||
--tx-dim: #888; |
||||
--tx-faint: #999; |
||||
--tx-ghost: #aaa; |
||||
--tx-file: #aaa; |
||||
--tx-empty: #bbb; |
||||
--ac: #1a9940; |
||||
--dirty: #b37a00 |
||||
} |
||||
|
||||
*, |
||||
*::before, |
||||
*::after { |
||||
box-sizing: border-box; |
||||
margin: 0; |
||||
padding: 0 |
||||
} |
||||
|
||||
body { |
||||
background: var(--bg); |
||||
color: var(--tx); |
||||
font-family: monospace; |
||||
height: 100vh; |
||||
display: flex; |
||||
flex-direction: column; |
||||
overflow: hidden; |
||||
font-size: 12px |
||||
} |
||||
|
||||
#topbar { |
||||
height: 36px; |
||||
background: var(--bg-bar); |
||||
border-bottom: 1px solid var(--bd); |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 8px; |
||||
padding: 0 12px; |
||||
flex-shrink: 0 |
||||
} |
||||
|
||||
#topbar .app-name { |
||||
color: var(--tx-dim); |
||||
font-size: 11px; |
||||
letter-spacing: .12em; |
||||
text-transform: uppercase; |
||||
margin-right: 4px |
||||
} |
||||
|
||||
.tb-btn { |
||||
background: var(--bg-raised); |
||||
color: var(--tx-sub); |
||||
border: 1px solid var(--bd-btn); |
||||
border-radius: 3px; |
||||
padding: 3px 10px; |
||||
font-family: monospace; |
||||
font-size: 11px; |
||||
cursor: pointer; |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 5px; |
||||
white-space: nowrap |
||||
} |
||||
|
||||
.tb-btn:hover { |
||||
background: var(--bg-hover2); |
||||
color: var(--tx-hover); |
||||
border-color: var(--bd-hover) |
||||
} |
||||
|
||||
.tb-btn svg { |
||||
width: 13px; |
||||
height: 13px; |
||||
stroke: currentColor; |
||||
fill: none; |
||||
stroke-width: 1.8; |
||||
stroke-linecap: round; |
||||
stroke-linejoin: round; |
||||
flex-shrink: 0 |
||||
} |
||||
|
||||
#file-input { |
||||
display: none |
||||
} |
||||
|
||||
#filename { |
||||
color: var(--tx-file); |
||||
font-size: 11px; |
||||
margin-left: 2px; |
||||
max-width: 200px; |
||||
overflow: hidden; |
||||
text-overflow: ellipsis; |
||||
white-space: nowrap |
||||
} |
||||
|
||||
.tb-sep { |
||||
width: 1px; |
||||
height: 18px; |
||||
background: var(--bd) |
||||
} |
||||
|
||||
.dirty { |
||||
color: var(--dirty) !important |
||||
} |
||||
|
||||
#main { |
||||
flex: 1; |
||||
display: flex; |
||||
min-height: 0 |
||||
} |
||||
|
||||
#left { |
||||
width: 48%; |
||||
border-right: 1px solid var(--bd-inner); |
||||
display: flex; |
||||
flex-direction: column; |
||||
padding: 14px; |
||||
gap: 10px; |
||||
overflow-y: auto; |
||||
flex-shrink: 0 |
||||
} |
||||
|
||||
#display-box { |
||||
width: 100%; |
||||
aspect-ratio: 2/1; |
||||
background: var(--bg-canvas); |
||||
border: 1px solid var(--bd); |
||||
border-radius: 3px; |
||||
overflow: hidden |
||||
} |
||||
|
||||
#canvas_root { |
||||
width: 100%; |
||||
height: 100%; |
||||
display: block; |
||||
image-rendering: pixelated; |
||||
image-rendering: crisp-edges |
||||
} |
||||
|
||||
.pv-label { |
||||
font-size: 10px; |
||||
color: var(--tx-ghost); |
||||
letter-spacing: .1em; |
||||
text-transform: uppercase |
||||
} |
||||
|
||||
#demo-btns { |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
gap: 5px; |
||||
margin-top: 6px |
||||
} |
||||
|
||||
#demo-btns>div { |
||||
display: contents; |
||||
} |
||||
|
||||
canvas.pixel-editor { |
||||
display: block; |
||||
cursor: crosshair; |
||||
image-rendering: pixelated; |
||||
max-width: 100%; |
||||
border: 1px solid var(--bd-inner); |
||||
} |
||||
|
||||
.anim-editor { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 4px; |
||||
} |
||||
|
||||
.anim-frame-tabs { |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
gap: 3px; |
||||
align-items: center; |
||||
} |
||||
|
||||
.anim-frame-tab { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 2px; |
||||
padding: 2px 6px; |
||||
border: 1px solid var(--bd-inner); |
||||
border-radius: 3px; |
||||
cursor: pointer; |
||||
font-size: 11px; |
||||
user-select: none; |
||||
} |
||||
|
||||
.anim-frame-tab.active { |
||||
border-color: var(--accent, #EC0); |
||||
color: var(--accent, #EC0); |
||||
} |
||||
|
||||
.x-btn-sm { |
||||
background: none; |
||||
border: none; |
||||
color: inherit; |
||||
cursor: pointer; |
||||
padding: 0 2px; |
||||
font-size: 13px; |
||||
line-height: 1; |
||||
opacity: 0.6; |
||||
} |
||||
|
||||
.x-btn-sm:hover { |
||||
opacity: 1; |
||||
} |
||||
|
||||
#sec-meta { |
||||
background: var(--bg-sunken); |
||||
border: 1px solid var(--bd-inner); |
||||
border-radius: 3px; |
||||
padding: 8px 10px; |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 6px |
||||
} |
||||
|
||||
.meta-row { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 8px; |
||||
font-size: 11px |
||||
} |
||||
|
||||
.meta-row label { |
||||
color: var(--tx-faint); |
||||
min-width: 90px |
||||
} |
||||
|
||||
.meta-val { |
||||
color: var(--tx-meta) |
||||
} |
||||
|
||||
.green { |
||||
color: var(--ac) |
||||
} |
||||
|
||||
select.meta-val { |
||||
background: var(--bg-input); |
||||
color: var(--ac); |
||||
border: 1px solid var(--bd-inner); |
||||
border-radius: 3px; |
||||
padding: 1px 4px; |
||||
} |
||||
|
||||
.flag-check { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 5px; |
||||
font-size: 11px; |
||||
color: var(--tx-dim); |
||||
cursor: pointer |
||||
} |
||||
|
||||
.flag-check input { |
||||
accent-color: var(--ac); |
||||
cursor: pointer |
||||
} |
||||
|
||||
#right { |
||||
flex: 1; |
||||
display: flex; |
||||
flex-direction: column; |
||||
min-height: 0; |
||||
overflow: hidden |
||||
} |
||||
|
||||
#right-hdr { |
||||
height: 36px; |
||||
background: var(--bg-sunken); |
||||
border-bottom: 1px solid var(--bd-inner); |
||||
display: flex; |
||||
align-items: center; |
||||
padding: 0 10px; |
||||
gap: 6px; |
||||
flex-shrink: 0 |
||||
} |
||||
|
||||
#right-hdr .rh-title { |
||||
flex: 1; |
||||
color: var(--tx-faint); |
||||
font-size: 11px; |
||||
letter-spacing: .08em; |
||||
text-transform: uppercase |
||||
} |
||||
|
||||
#sections-wrap { |
||||
flex: 1; |
||||
overflow-y: auto; |
||||
padding: 8px |
||||
} |
||||
|
||||
.empty-state { |
||||
color: var(--tx-empty); |
||||
font-size: 11px; |
||||
padding: 28px 16px; |
||||
text-align: center; |
||||
line-height: 2 |
||||
} |
||||
|
||||
/* section card */ |
||||
.sec-card { |
||||
border: 1px solid var(--bd-card); |
||||
border-radius: 3px; |
||||
margin-bottom: 5px; |
||||
overflow: hidden |
||||
} |
||||
|
||||
.sec-card.active { |
||||
border-color: var(--bd-active) |
||||
} |
||||
|
||||
.sec-hdr { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 7px; |
||||
padding: 6px 8px; |
||||
cursor: pointer; |
||||
user-select: none; |
||||
background: var(--bg-raised); |
||||
position: relative |
||||
} |
||||
|
||||
.sec-hdr:hover { |
||||
background: var(--bg-hover) |
||||
} |
||||
|
||||
.sec-card.active .sec-hdr, |
||||
.sec-hdr.active { |
||||
background: var(--bg-active) |
||||
} |
||||
|
||||
.sec-arrow { |
||||
color: var(--tx-ghost); |
||||
font-size: 13px; |
||||
line-height: 1; |
||||
transition: transform .12s; |
||||
flex-shrink: 0 |
||||
} |
||||
|
||||
.sec-card.open .sec-arrow { |
||||
transform: rotate(90deg) |
||||
} |
||||
|
||||
.sec-label { |
||||
flex: 1; |
||||
color: var(--tx-label); |
||||
font-size: 11px |
||||
} |
||||
|
||||
.sec-badge { |
||||
font-size: 10px; |
||||
color: var(--ac); |
||||
border: 1px solid var(--bd-active); |
||||
background: var(--bg-accent); |
||||
border-radius: 2px; |
||||
padding: 1px 6px; |
||||
flex-shrink: 0 |
||||
} |
||||
|
||||
/* red × - section & element */ |
||||
.x-btn { |
||||
background: none; |
||||
border: none; |
||||
color: var(--bd); |
||||
cursor: pointer; |
||||
font-size: 15px; |
||||
line-height: 1; |
||||
padding: 2px 5px; |
||||
border-radius: 2px; |
||||
flex-shrink: 0; |
||||
transition: color .1s, background .1s |
||||
} |
||||
|
||||
.x-btn:hover { |
||||
color: #ff4444; |
||||
background: var(--bg-xhover) |
||||
} |
||||
|
||||
/* element */ |
||||
.el-list { |
||||
background: var(--bg-deep); |
||||
border-top: 1px solid var(--bd-deep); |
||||
padding: 6px |
||||
} |
||||
|
||||
.el-item { |
||||
border: 1px solid var(--bd-inner); |
||||
border-radius: 3px; |
||||
background: var(--bg-sunken); |
||||
margin-bottom: 4px; |
||||
overflow: hidden |
||||
} |
||||
|
||||
.el-item.active { |
||||
border-color: var(--ac) |
||||
} |
||||
|
||||
.el-item-hdr { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 6px; |
||||
padding: 5px 8px; |
||||
cursor: pointer |
||||
} |
||||
|
||||
.el-item-hdr:hover { |
||||
background: var(--bg-hover) |
||||
} |
||||
|
||||
.el-type { |
||||
font-size: 10px; |
||||
color: var(--tx-meta); |
||||
border: 1px solid var(--bd-type); |
||||
border-radius: 2px; |
||||
padding: 1px 6px; |
||||
flex-shrink: 0 |
||||
} |
||||
|
||||
.el-item.active .el-type { |
||||
color: var(--ac); |
||||
border-color: var(--bd-active) |
||||
} |
||||
|
||||
.el-name { |
||||
flex: 1; |
||||
color: var(--tx-dim); |
||||
font-size: 11px; |
||||
overflow: hidden; |
||||
text-overflow: ellipsis; |
||||
white-space: nowrap; |
||||
max-width: 120px |
||||
} |
||||
|
||||
.el-fields { |
||||
padding: 6px 8px 8px; |
||||
border-top: 1px solid var(--bg-bar); |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 5px |
||||
} |
||||
|
||||
.fields-grid { |
||||
display: grid; |
||||
grid-template-columns: 1fr 1fr; |
||||
gap: 4px 10px |
||||
} |
||||
|
||||
.field { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 2px |
||||
} |
||||
|
||||
.field.full { |
||||
grid-column: 1/-1 |
||||
} |
||||
|
||||
.field>label { |
||||
font-size: 10px; |
||||
color: var(--tx-faint) |
||||
} |
||||
|
||||
.field input[type=text], |
||||
.field input[type=number], |
||||
.field input[type=datetime-local], |
||||
.field select, |
||||
.field textarea { |
||||
background: var(--bg-input); |
||||
color: var(--tx-sub); |
||||
border: 1px solid var(--bd-card); |
||||
border-radius: 2px; |
||||
padding: 3px 6px; |
||||
font-family: monospace; |
||||
font-size: 11px; |
||||
width: 100%; |
||||
outline: none |
||||
} |
||||
|
||||
.field input:focus, |
||||
.field select:focus, |
||||
.field textarea:focus { |
||||
border-color: var(--ac); |
||||
color: var(--tx) |
||||
} |
||||
|
||||
.field textarea { |
||||
resize: vertical; |
||||
min-height: 44px; |
||||
line-height: 1.4 |
||||
} |
||||
|
||||
.field select option { |
||||
background: var(--bg-bar); |
||||
color: var(--tx-ghost) |
||||
} |
||||
|
||||
.flags-row { |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
gap: 10px; |
||||
padding: 2px 0 |
||||
} |
||||
|
||||
.add-el { |
||||
background: transparent; |
||||
color: var(--tx-ghost); |
||||
border: 1px dashed var(--bd-card); |
||||
border-radius: 3px; |
||||
padding: 5px 8px; |
||||
font-family: monospace; |
||||
font-size: 11px; |
||||
cursor: pointer; |
||||
width: 100%; |
||||
text-align: center; |
||||
margin-top: 2px |
||||
} |
||||
|
||||
.add-el:hover { |
||||
color: var(--tx-sub); |
||||
border-color: var(--bd-hover) |
||||
} |
||||
|
||||
/* confirm dialog */ |
||||
#confirm-overlay { |
||||
display: none; |
||||
position: fixed; |
||||
inset: 0; |
||||
background: var(--bg-overlay); |
||||
z-index: 100; |
||||
align-items: center; |
||||
justify-content: center |
||||
} |
||||
|
||||
#confirm-overlay.show { |
||||
display: flex |
||||
} |
||||
|
||||
#confirm-box { |
||||
background: var(--bg-bar); |
||||
border: 1px solid var(--bd-strong); |
||||
border-radius: 4px; |
||||
padding: 20px 24px; |
||||
max-width: 340px; |
||||
width: 90%; |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 14px |
||||
} |
||||
|
||||
#confirm-msg { |
||||
color: var(--tx-ghost); |
||||
font-size: 12px; |
||||
line-height: 1.6 |
||||
} |
||||
|
||||
#confirm-btns { |
||||
display: flex; |
||||
gap: 8px; |
||||
justify-content: flex-end |
||||
} |
||||
|
||||
.cb { |
||||
background: var(--bg-raised); |
||||
color: var(--tx-sub); |
||||
border: 1px solid var(--bd-btn); |
||||
border-radius: 3px; |
||||
padding: 4px 14px; |
||||
font-family: monospace; |
||||
font-size: 11px; |
||||
cursor: pointer |
||||
} |
||||
|
||||
.cb:hover { |
||||
background: var(--bg-hover2); |
||||
color: var(--tx-hover) |
||||
} |
||||
|
||||
.cb.primary { |
||||
border-color: var(--bd-active); |
||||
color: var(--ac) |
||||
} |
||||
|
||||
.cb.primary:hover { |
||||
background: var(--bg-active) |
||||
} |
||||
|
||||
#mob-add-section { |
||||
display: none; |
||||
} |
||||
|
||||
canvas.pixel-editor { |
||||
touch-action: none; |
||||
/* belt-and-suspenders alongside preventDefault */ |
||||
-webkit-user-select: none; |
||||
user-select: none; |
||||
} |
||||
|
||||
.pv-label select { |
||||
background: var(--bg-input); |
||||
color: var(--ac); |
||||
border: 1px solid var(--bd-inner); |
||||
border-radius: 3px; |
||||
padding: 1px 14px; |
||||
font-family: monospace; |
||||
font-size: 10px; |
||||
letter-spacing: .1em; |
||||
} |
||||
@ -1,29 +0,0 @@ |
||||
{ |
||||
"compilerOptions": { |
||||
// Environment setup & latest features |
||||
"lib": ["ESNext", "DOM"], |
||||
"target": "ESNext", |
||||
"module": "Preserve", |
||||
"moduleDetection": "force", |
||||
"jsx": "react-jsx", |
||||
"allowJs": true, |
||||
|
||||
// Bundler mode |
||||
"moduleResolution": "bundler", |
||||
"allowImportingTsExtensions": true, |
||||
"verbatimModuleSyntax": true, |
||||
"noEmit": true, |
||||
|
||||
// Best practices |
||||
"strict": true, |
||||
"skipLibCheck": true, |
||||
"noFallthroughCasesInSwitch": true, |
||||
"noUncheckedIndexedAccess": true, |
||||
"noImplicitOverride": true, |
||||
|
||||
// Some stricter flags (disabled by default) |
||||
"noUnusedLocals": false, |
||||
"noUnusedParameters": false, |
||||
"noPropertyAccessFromIndexSignature": false |
||||
} |
||||
} |
||||
@ -1,156 +0,0 @@ |
||||
import { readFileSync, writeFileSync } from "fs"; |
||||
import { resolve, dirname, relative } from "path"; |
||||
import ts from "typescript"; |
||||
|
||||
const ENTRY = resolve("src/index.ts"); |
||||
const OUT = resolve("dist/index.d.ts"); |
||||
|
||||
const configFile = ts.findConfigFile("./", ts.sys.fileExists, "tsconfig.json"); |
||||
const configHost: ts.ParseConfigFileHost = { |
||||
...ts.sys, |
||||
onUnRecoverableConfigFileDiagnostic: (d) => { |
||||
throw new Error(ts.flattenDiagnosticMessageText(d.messageText, "\n")); |
||||
}, |
||||
}; |
||||
|
||||
const parsed = ts.getParsedCommandLineOfConfigFile( |
||||
configFile!, |
||||
{ emitDeclarationOnly: true, declaration: true, noEmit: false }, |
||||
configHost |
||||
)!; |
||||
|
||||
const program = ts.createProgram([ENTRY], parsed.options); |
||||
const checker = program.getTypeChecker(); |
||||
|
||||
const emitted = new Set<string>(); // dedupe across files
|
||||
const lines: string[] = ["// Auto-generated by build-dts.ts", ""]; |
||||
|
||||
function relativePath(fullfilename: string): string { |
||||
return relative(process.cwd(), fullfilename); |
||||
} |
||||
|
||||
function fileText(sf: ts.SourceFile, node: ts.Node): string { |
||||
return sf.text.slice(node.getFullStart(), node.getEnd()).trim(); |
||||
} |
||||
|
||||
function tryResolveType(sf: ts.SourceFile, node: ts.TypeAliasDeclaration): string | null { |
||||
try { |
||||
const type = checker.getTypeAtLocation(node.name); |
||||
const resolved = checker.typeToString( |
||||
type, |
||||
undefined, |
||||
ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseFullyQualifiedType |
||||
); |
||||
if (resolved === node.name.text) return null; |
||||
return `export type ${node.name.text} = ${resolved};`; |
||||
} catch { |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
function visitFile(sf: ts.SourceFile) { |
||||
function visit(node: ts.Node) { |
||||
const isExported = (n: ts.Node) => |
||||
(n as any).modifiers?.some((m: ts.Modifier) => m.kind === ts.SyntaxKind.ExportKeyword); |
||||
|
||||
// export * from "./x" or export type * from "./x" — follow and inline
|
||||
if (ts.isExportDeclaration(node)) { |
||||
const modSpec = node.moduleSpecifier; |
||||
if (modSpec && ts.isStringLiteral(modSpec)) { |
||||
const resolved = ts.resolveModuleName( |
||||
modSpec.text, |
||||
sf.fileName, |
||||
parsed.options, |
||||
ts.sys |
||||
).resolvedModule; |
||||
|
||||
if (resolved && !resolved.isExternalLibraryImport) { |
||||
const targetSf = program.getSourceFile(resolved.resolvedFileName); |
||||
if (targetSf) { |
||||
visitFile(targetSf); // recurse into the re-exported file
|
||||
return; |
||||
} |
||||
} |
||||
} |
||||
// external or unresolved — copy verbatim
|
||||
const text = fileText(sf, node); |
||||
if (!emitted.has(text)) { |
||||
emitted.add(text); |
||||
lines.push(`// ${relativePath(sf.fileName)}\n`); |
||||
lines.push(text); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
// export type Foo = ...
|
||||
if (ts.isTypeAliasDeclaration(node) && isExported(node)) { |
||||
const resolved = tryResolveType(sf, node); |
||||
const text = resolved ?? fileText(sf, node); |
||||
if (!emitted.has(node.name.text)) { |
||||
emitted.add(node.name.text); |
||||
lines.push(`// ${relativePath(sf.fileName)}\n`); |
||||
lines.push(text); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
// export interface, enum, const enum
|
||||
if ( |
||||
(ts.isInterfaceDeclaration(node) || ts.isEnumDeclaration(node)) && |
||||
isExported(node) |
||||
) { |
||||
const text = fileText(sf, node); |
||||
if (!emitted.has((node as any).name.text)) { |
||||
emitted.add((node as any).name.text); |
||||
lines.push(`// ${relativePath(sf.fileName)}\n`); |
||||
lines.push(text); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
// export class
|
||||
if (ts.isClassDeclaration(node) && isExported(node) && node.name) { |
||||
const text = fileText(sf, node); |
||||
if (!emitted.has(node.name.text)) { |
||||
emitted.add(node.name.text); |
||||
lines.push(`// ${relativePath(sf.fileName)}\n`); |
||||
lines.push(text); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
// export function / export const
|
||||
if ( |
||||
(ts.isFunctionDeclaration(node) || ts.isVariableStatement(node)) && |
||||
isExported(node) |
||||
) { |
||||
try { |
||||
if (ts.isFunctionDeclaration(node) && node.name) { |
||||
const sym = checker.getSymbolAtLocation(node.name); |
||||
if (sym && !emitted.has(sym.name)) { |
||||
emitted.add(sym.name); |
||||
const type = checker.getTypeOfSymbolAtLocation(sym, node); |
||||
for (const sig of type.getCallSignatures()) { |
||||
lines.push(`export declare function ${sym.name}${checker.signatureToString(sig)};`); |
||||
} |
||||
return; |
||||
} |
||||
} |
||||
} catch { } |
||||
const text = fileText(sf, node); |
||||
if (!emitted.has(text)) { emitted.add(text); lines.push(text); } |
||||
return; |
||||
} |
||||
|
||||
ts.forEachChild(node, visit); |
||||
} |
||||
|
||||
ts.forEachChild(sf, visit); |
||||
} |
||||
|
||||
const entryFile = program.getSourceFile(ENTRY)!; |
||||
visitFile(entryFile); |
||||
lines.push(""); |
||||
|
||||
writeFileSync(OUT, lines.join("\n")); |
||||
// console.log(`✓ wrote ${OUT}`);
|
||||
@ -0,0 +1,9 @@ |
||||
// browser.ts — IIFE-style browser entry point
|
||||
// Built by: bun build src/browser.ts --target browser --outfile public/mono-display.js
|
||||
// Attaches everything to window.MonoDisplay so inline <script> tags can use it
|
||||
// without ES module syntax.
|
||||
|
||||
import * as MonoDisplay from "./index"; |
||||
|
||||
// Expose on globalThis (= window in browser, globalThis elsewhere)
|
||||
(globalThis as typeof globalThis & { MonoDisplay: typeof MonoDisplay }).MonoDisplay = MonoDisplay; |
||||
@ -1,26 +0,0 @@ |
||||
// --- Theme --------------------------------------------------------------------
|
||||
var _THEMES = ['dark', 'light', 'auto']; |
||||
function _getTheme() { return document.documentElement.getAttribute('data-theme') || 'auto'; } |
||||
function _applyTheme(t: string | undefined) { |
||||
if (t === 'auto') { document.documentElement.removeAttribute('data-theme'); localStorage.removeItem('theme'); } |
||||
else { document.documentElement.setAttribute('data-theme', t); localStorage.setItem('theme', t); } |
||||
_updateThemeBtn(); |
||||
} |
||||
export function cycleTheme() { |
||||
var cur = _getTheme(), idx = _THEMES.indexOf(cur); |
||||
_applyTheme(_THEMES[(idx + 1) % _THEMES.length]); |
||||
} |
||||
function _detectDR() { |
||||
return !!(document.querySelector('meta[name="darkreader"]') || |
||||
document.querySelector('style[data-darkreader-style]') || |
||||
document.documentElement.getAttribute('data-darkreader-mode')); |
||||
} |
||||
function _updateThemeBtn() { |
||||
var btn = document.getElementById('theme-toggle'); |
||||
if (!btn) return; |
||||
var t = _getTheme(), dr = _detectDR(); |
||||
var lbl = { dark: '☾ dark', light: '☀ light', auto: '⊙ auto' }; |
||||
btn.textContent = (dr ? 'DR · ' : '') + (lbl[t] || lbl.auto); |
||||
btn.title = dr ? 'DarkReader active - controlling theme' : ('Theme: ' + t + ' (click to cycle)'); |
||||
} |
||||
window.addEventListener('DOMContentLoaded', _updateThemeBtn); |
||||