diff --git a/chromakey.js b/chromakey.js deleted file mode 100644 index 8b869fb..0000000 --- a/chromakey.js +++ /dev/null @@ -1,53 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -// Using Jimp for image processing -const { Jimp } = require('jimp'); - -async function chromakeyImage() { - try { - const inputPath = path.join(__dirname, 'website', 'screenborder.jpg'); - const outputPath = path.join(__dirname, 'website', 'screenborder.png'); - - console.log('Loading image...'); - const image = await Jimp.read(inputPath); - - console.log('Removing green screen...'); - - // Scan through each pixel - image.scan(0, 0, image.bitmap.width, image.bitmap.height, function(x, y, idx) { - const red = this.bitmap.data[idx + 0]; - const green = this.bitmap.data[idx + 1]; - const blue = this.bitmap.data[idx + 2]; - const alpha = this.bitmap.data[idx + 3]; - - // Very aggressive green detection - const isGreen = green > red * 1.05 && green > blue * 1.05 && green > 30; - - // Catch any bright greens - const isBrightGreen = green > 120 && green > red && green > blue; - - if (isGreen || isBrightGreen) { - // Make pixel transparent - this.bitmap.data[idx + 3] = 0; - } else if (green > red || green > blue) { - // Aggressively reduce alpha for any greenish tint - const greenness = Math.max( - (green - red) / 255, - (green - blue) / 255 - ); - this.bitmap.data[idx + 3] = Math.floor(alpha * (1 - greenness)); - } - }); - - console.log('Saving PNG...'); - await image.write(outputPath); - - console.log('✓ Chromakey complete! Saved to website/screenborder.png'); - } catch (error) { - console.error('✗ Chromakey failed:', error); - process.exit(1); - } -} - -chromakeyImage(); diff --git a/create-bulge-map.js b/create-bulge-map.js deleted file mode 100644 index 8e7cd60..0000000 --- a/create-bulge-map.js +++ /dev/null @@ -1,46 +0,0 @@ -const { Jimp } = require('jimp'); -const path = require('path'); - -async function createDisplacementMap() { - const size = 512; - const image = new Jimp({ width: size, height: size }); - - // Create a gentle CRT bulge displacement map - // Center appears to bulge outward (magnified) - // R channel = horizontal displacement - // G channel = vertical displacement - // 128 = no displacement, <128 = pull toward 0, >128 = push toward max - - for (let y = 0; y < size; y++) { - for (let x = 0; x < size; x++) { - // Normalize to -1 to 1 range (center is 0,0) - const nx = (x / (size - 1)) * 2 - 1; - const ny = (y / (size - 1)) * 2 - 1; - - // Use smooth cubic falloff for gentle curve - // Pull edges slightly toward center (pincushion) - const distSq = nx * nx + ny * ny; - const strength = distSq * 0.15; // Gentle effect - - // Direction away from center (positive = away from center) - const dx = nx * strength; - const dy = ny * strength; - - // Convert to 0-255 range (128 = no displacement) - const r = Math.floor(128 + dx * 127); - const g = Math.floor(128 + dy * 127); - - const idx = (y * size + x) * 4; - image.bitmap.data[idx + 0] = Math.max(0, Math.min(255, r)); - image.bitmap.data[idx + 1] = Math.max(0, Math.min(255, g)); - image.bitmap.data[idx + 2] = 128; - image.bitmap.data[idx + 3] = 255; - } - } - - const outputPath = path.join(__dirname, 'website', 'bulge-map.png'); - await image.write(outputPath); - console.log('✓ Displacement map created: website/bulge-map.png'); -} - -createDisplacementMap(); diff --git a/website/avatar.webp b/website/avatar.webp deleted file mode 100644 index 8c4da01..0000000 Binary files a/website/avatar.webp and /dev/null differ diff --git a/website/blocks/anvil_base.png b/website/blocks/anvil_base.png deleted file mode 100644 index cfc1fc8..0000000 Binary files a/website/blocks/anvil_base.png and /dev/null differ diff --git a/website/blocks/anvil_top_damaged_0.png b/website/blocks/anvil_top_damaged_0.png deleted file mode 100644 index 395dea4..0000000 Binary files a/website/blocks/anvil_top_damaged_0.png and /dev/null differ diff --git a/website/blocks/anvil_top_damaged_1.png b/website/blocks/anvil_top_damaged_1.png deleted file mode 100644 index eddc47f..0000000 Binary files a/website/blocks/anvil_top_damaged_1.png and /dev/null differ diff --git a/website/blocks/anvil_top_damaged_2.png b/website/blocks/anvil_top_damaged_2.png deleted file mode 100644 index 6cade1f..0000000 Binary files a/website/blocks/anvil_top_damaged_2.png and /dev/null differ diff --git a/website/blocks/beacon.png b/website/blocks/beacon.png deleted file mode 100644 index a69e59d..0000000 Binary files a/website/blocks/beacon.png and /dev/null differ diff --git a/website/blocks/bed_feet_end.png b/website/blocks/bed_feet_end.png deleted file mode 100644 index 6e1a4be..0000000 Binary files a/website/blocks/bed_feet_end.png and /dev/null differ diff --git a/website/blocks/bed_feet_side.png b/website/blocks/bed_feet_side.png deleted file mode 100644 index 3ce06f3..0000000 Binary files a/website/blocks/bed_feet_side.png and /dev/null differ diff --git a/website/blocks/bed_feet_top.png b/website/blocks/bed_feet_top.png deleted file mode 100644 index b96d357..0000000 Binary files a/website/blocks/bed_feet_top.png and /dev/null differ diff --git a/website/blocks/bed_head_end.png b/website/blocks/bed_head_end.png deleted file mode 100644 index b684c9a..0000000 Binary files a/website/blocks/bed_head_end.png and /dev/null differ diff --git a/website/blocks/bed_head_side.png b/website/blocks/bed_head_side.png deleted file mode 100644 index 3270b4a..0000000 Binary files a/website/blocks/bed_head_side.png and /dev/null differ diff --git a/website/blocks/bed_head_top.png b/website/blocks/bed_head_top.png deleted file mode 100644 index 2ab1090..0000000 Binary files a/website/blocks/bed_head_top.png and /dev/null differ diff --git a/website/blocks/bedrock.png b/website/blocks/bedrock.png deleted file mode 100644 index 1643c99..0000000 Binary files a/website/blocks/bedrock.png and /dev/null differ diff --git a/website/blocks/bookshelf.png b/website/blocks/bookshelf.png deleted file mode 100644 index 4c87f0f..0000000 Binary files a/website/blocks/bookshelf.png and /dev/null differ diff --git a/website/blocks/brewing_stand.png b/website/blocks/brewing_stand.png deleted file mode 100644 index 60832aa..0000000 Binary files a/website/blocks/brewing_stand.png and /dev/null differ diff --git a/website/blocks/brewing_stand_base.png b/website/blocks/brewing_stand_base.png deleted file mode 100644 index 0742fbf..0000000 Binary files a/website/blocks/brewing_stand_base.png and /dev/null differ diff --git a/website/blocks/brick.png b/website/blocks/brick.png deleted file mode 100644 index fd6959c..0000000 Binary files a/website/blocks/brick.png and /dev/null differ diff --git a/website/blocks/cactus_bottom.png b/website/blocks/cactus_bottom.png deleted file mode 100644 index 6f10bc0..0000000 Binary files a/website/blocks/cactus_bottom.png and /dev/null differ diff --git a/website/blocks/cactus_side.png b/website/blocks/cactus_side.png deleted file mode 100644 index 9c55503..0000000 Binary files a/website/blocks/cactus_side.png and /dev/null differ diff --git a/website/blocks/cactus_top.png b/website/blocks/cactus_top.png deleted file mode 100644 index f182e84..0000000 Binary files a/website/blocks/cactus_top.png and /dev/null differ diff --git a/website/blocks/cake_bottom.png b/website/blocks/cake_bottom.png deleted file mode 100644 index d93b15a..0000000 Binary files a/website/blocks/cake_bottom.png and /dev/null differ diff --git a/website/blocks/cake_inner.png b/website/blocks/cake_inner.png deleted file mode 100644 index ce7ce69..0000000 Binary files a/website/blocks/cake_inner.png and /dev/null differ diff --git a/website/blocks/cake_side.png b/website/blocks/cake_side.png deleted file mode 100644 index 343a023..0000000 Binary files a/website/blocks/cake_side.png and /dev/null differ diff --git a/website/blocks/cake_top.png b/website/blocks/cake_top.png deleted file mode 100644 index 2947892..0000000 Binary files a/website/blocks/cake_top.png and /dev/null differ diff --git a/website/blocks/carrots_stage_0.png b/website/blocks/carrots_stage_0.png deleted file mode 100644 index c1ef732..0000000 Binary files a/website/blocks/carrots_stage_0.png and /dev/null differ diff --git a/website/blocks/carrots_stage_1.png b/website/blocks/carrots_stage_1.png deleted file mode 100644 index 1275f4f..0000000 Binary files a/website/blocks/carrots_stage_1.png and /dev/null differ diff --git a/website/blocks/carrots_stage_2.png b/website/blocks/carrots_stage_2.png deleted file mode 100644 index b7347df..0000000 Binary files a/website/blocks/carrots_stage_2.png and /dev/null differ diff --git a/website/blocks/carrots_stage_3.png b/website/blocks/carrots_stage_3.png deleted file mode 100644 index 2391be8..0000000 Binary files a/website/blocks/carrots_stage_3.png and /dev/null differ diff --git a/website/blocks/cauldron_bottom.png b/website/blocks/cauldron_bottom.png deleted file mode 100644 index 8328307..0000000 Binary files a/website/blocks/cauldron_bottom.png and /dev/null differ diff --git a/website/blocks/cauldron_inner.png b/website/blocks/cauldron_inner.png deleted file mode 100644 index d5a30dd..0000000 Binary files a/website/blocks/cauldron_inner.png and /dev/null differ diff --git a/website/blocks/cauldron_side.png b/website/blocks/cauldron_side.png deleted file mode 100644 index df42f98..0000000 Binary files a/website/blocks/cauldron_side.png and /dev/null differ diff --git a/website/blocks/cauldron_top.png b/website/blocks/cauldron_top.png deleted file mode 100644 index e263fc5..0000000 Binary files a/website/blocks/cauldron_top.png and /dev/null differ diff --git a/website/blocks/clay.png b/website/blocks/clay.png deleted file mode 100644 index c19e031..0000000 Binary files a/website/blocks/clay.png and /dev/null differ diff --git a/website/blocks/coal_block.png b/website/blocks/coal_block.png deleted file mode 100644 index 024404b..0000000 Binary files a/website/blocks/coal_block.png and /dev/null differ diff --git a/website/blocks/coal_ore.png b/website/blocks/coal_ore.png deleted file mode 100644 index 49486d2..0000000 Binary files a/website/blocks/coal_ore.png and /dev/null differ diff --git a/website/blocks/coarse_dirt.png b/website/blocks/coarse_dirt.png deleted file mode 100644 index d646225..0000000 Binary files a/website/blocks/coarse_dirt.png and /dev/null differ diff --git a/website/blocks/cobblestone.png b/website/blocks/cobblestone.png deleted file mode 100644 index da3498c..0000000 Binary files a/website/blocks/cobblestone.png and /dev/null differ diff --git a/website/blocks/cobblestone_mossy.png b/website/blocks/cobblestone_mossy.png deleted file mode 100644 index 29449e3..0000000 Binary files a/website/blocks/cobblestone_mossy.png and /dev/null differ diff --git a/website/blocks/cocoa_stage_0.png b/website/blocks/cocoa_stage_0.png deleted file mode 100644 index 25892eb..0000000 Binary files a/website/blocks/cocoa_stage_0.png and /dev/null differ diff --git a/website/blocks/cocoa_stage_1.png b/website/blocks/cocoa_stage_1.png deleted file mode 100644 index d0098ff..0000000 Binary files a/website/blocks/cocoa_stage_1.png and /dev/null differ diff --git a/website/blocks/cocoa_stage_2.png b/website/blocks/cocoa_stage_2.png deleted file mode 100644 index db28c7b..0000000 Binary files a/website/blocks/cocoa_stage_2.png and /dev/null differ diff --git a/website/blocks/command_block.png b/website/blocks/command_block.png deleted file mode 100644 index 4459675..0000000 Binary files a/website/blocks/command_block.png and /dev/null differ diff --git a/website/blocks/comparator_off.png b/website/blocks/comparator_off.png deleted file mode 100644 index c9527bc..0000000 Binary files a/website/blocks/comparator_off.png and /dev/null differ diff --git a/website/blocks/comparator_on.png b/website/blocks/comparator_on.png deleted file mode 100644 index 2e4fb7a..0000000 Binary files a/website/blocks/comparator_on.png and /dev/null differ diff --git a/website/blocks/crafting_table_front.png b/website/blocks/crafting_table_front.png deleted file mode 100644 index 11986a4..0000000 Binary files a/website/blocks/crafting_table_front.png and /dev/null differ diff --git a/website/blocks/crafting_table_side.png b/website/blocks/crafting_table_side.png deleted file mode 100644 index 1c678b4..0000000 Binary files a/website/blocks/crafting_table_side.png and /dev/null differ diff --git a/website/blocks/crafting_table_top.png b/website/blocks/crafting_table_top.png deleted file mode 100644 index 5cd53a5..0000000 Binary files a/website/blocks/crafting_table_top.png and /dev/null differ diff --git a/website/blocks/daylight_detector_inverted_top.png b/website/blocks/daylight_detector_inverted_top.png deleted file mode 100644 index 194a30e..0000000 Binary files a/website/blocks/daylight_detector_inverted_top.png and /dev/null differ diff --git a/website/blocks/daylight_detector_side.png b/website/blocks/daylight_detector_side.png deleted file mode 100644 index ac273ea..0000000 Binary files a/website/blocks/daylight_detector_side.png and /dev/null differ diff --git a/website/blocks/daylight_detector_top.png b/website/blocks/daylight_detector_top.png deleted file mode 100644 index 3bfb2da..0000000 Binary files a/website/blocks/daylight_detector_top.png and /dev/null differ diff --git a/website/blocks/deadbush.png b/website/blocks/deadbush.png deleted file mode 100644 index c64e079..0000000 Binary files a/website/blocks/deadbush.png and /dev/null differ diff --git a/website/blocks/destroy_stage_0.png b/website/blocks/destroy_stage_0.png deleted file mode 100644 index f65b7ed..0000000 Binary files a/website/blocks/destroy_stage_0.png and /dev/null differ diff --git a/website/blocks/destroy_stage_1.png b/website/blocks/destroy_stage_1.png deleted file mode 100644 index 7c91596..0000000 Binary files a/website/blocks/destroy_stage_1.png and /dev/null differ diff --git a/website/blocks/destroy_stage_2.png b/website/blocks/destroy_stage_2.png deleted file mode 100644 index dadd6b0..0000000 Binary files a/website/blocks/destroy_stage_2.png and /dev/null differ diff --git a/website/blocks/destroy_stage_3.png b/website/blocks/destroy_stage_3.png deleted file mode 100644 index 583b436..0000000 Binary files a/website/blocks/destroy_stage_3.png and /dev/null differ diff --git a/website/blocks/destroy_stage_4.png b/website/blocks/destroy_stage_4.png deleted file mode 100644 index 9c18a58..0000000 Binary files a/website/blocks/destroy_stage_4.png and /dev/null differ diff --git a/website/blocks/destroy_stage_5.png b/website/blocks/destroy_stage_5.png deleted file mode 100644 index e4cee78..0000000 Binary files a/website/blocks/destroy_stage_5.png and /dev/null differ diff --git a/website/blocks/destroy_stage_6.png b/website/blocks/destroy_stage_6.png deleted file mode 100644 index 1299454..0000000 Binary files a/website/blocks/destroy_stage_6.png and /dev/null differ diff --git a/website/blocks/destroy_stage_7.png b/website/blocks/destroy_stage_7.png deleted file mode 100644 index 1a69168..0000000 Binary files a/website/blocks/destroy_stage_7.png and /dev/null differ diff --git a/website/blocks/destroy_stage_8.png b/website/blocks/destroy_stage_8.png deleted file mode 100644 index 47a93a9..0000000 Binary files a/website/blocks/destroy_stage_8.png and /dev/null differ diff --git a/website/blocks/destroy_stage_9.png b/website/blocks/destroy_stage_9.png deleted file mode 100644 index 9313aff..0000000 Binary files a/website/blocks/destroy_stage_9.png and /dev/null differ diff --git a/website/blocks/diamond_block.png b/website/blocks/diamond_block.png deleted file mode 100644 index f2e2e77..0000000 Binary files a/website/blocks/diamond_block.png and /dev/null differ diff --git a/website/blocks/diamond_ore.png b/website/blocks/diamond_ore.png deleted file mode 100644 index 735ecda..0000000 Binary files a/website/blocks/diamond_ore.png and /dev/null differ diff --git a/website/blocks/dirt.png b/website/blocks/dirt.png deleted file mode 100644 index 617d353..0000000 Binary files a/website/blocks/dirt.png and /dev/null differ diff --git a/website/blocks/dirt_podzol_side.png b/website/blocks/dirt_podzol_side.png deleted file mode 100644 index 5921d37..0000000 Binary files a/website/blocks/dirt_podzol_side.png and /dev/null differ diff --git a/website/blocks/dirt_podzol_top.png b/website/blocks/dirt_podzol_top.png deleted file mode 100644 index ebeda86..0000000 Binary files a/website/blocks/dirt_podzol_top.png and /dev/null differ diff --git a/website/blocks/dispenser_front_horizontal.png b/website/blocks/dispenser_front_horizontal.png deleted file mode 100644 index 3e09fde..0000000 Binary files a/website/blocks/dispenser_front_horizontal.png and /dev/null differ diff --git a/website/blocks/dispenser_front_vertical.png b/website/blocks/dispenser_front_vertical.png deleted file mode 100644 index 87a7837..0000000 Binary files a/website/blocks/dispenser_front_vertical.png and /dev/null differ diff --git a/website/blocks/door_acacia_lower.png b/website/blocks/door_acacia_lower.png deleted file mode 100644 index 2f57508..0000000 Binary files a/website/blocks/door_acacia_lower.png and /dev/null differ diff --git a/website/blocks/door_acacia_upper.png b/website/blocks/door_acacia_upper.png deleted file mode 100644 index 9b7e742..0000000 Binary files a/website/blocks/door_acacia_upper.png and /dev/null differ diff --git a/website/blocks/door_birch_lower.png b/website/blocks/door_birch_lower.png deleted file mode 100644 index 2c6f7d3..0000000 Binary files a/website/blocks/door_birch_lower.png and /dev/null differ diff --git a/website/blocks/door_birch_upper.png b/website/blocks/door_birch_upper.png deleted file mode 100644 index 1345b41..0000000 Binary files a/website/blocks/door_birch_upper.png and /dev/null differ diff --git a/website/blocks/door_dark_oak_lower.png b/website/blocks/door_dark_oak_lower.png deleted file mode 100644 index 7bb3ff2..0000000 Binary files a/website/blocks/door_dark_oak_lower.png and /dev/null differ diff --git a/website/blocks/door_dark_oak_upper.png b/website/blocks/door_dark_oak_upper.png deleted file mode 100644 index 27fa6cc..0000000 Binary files a/website/blocks/door_dark_oak_upper.png and /dev/null differ diff --git a/website/blocks/door_iron_lower.png b/website/blocks/door_iron_lower.png deleted file mode 100644 index dbc33ab..0000000 Binary files a/website/blocks/door_iron_lower.png and /dev/null differ diff --git a/website/blocks/door_iron_upper.png b/website/blocks/door_iron_upper.png deleted file mode 100644 index 56878fe..0000000 Binary files a/website/blocks/door_iron_upper.png and /dev/null differ diff --git a/website/blocks/door_jungle_lower.png b/website/blocks/door_jungle_lower.png deleted file mode 100644 index 4edfa3c..0000000 Binary files a/website/blocks/door_jungle_lower.png and /dev/null differ diff --git a/website/blocks/door_jungle_upper.png b/website/blocks/door_jungle_upper.png deleted file mode 100644 index d5201f8..0000000 Binary files a/website/blocks/door_jungle_upper.png and /dev/null differ diff --git a/website/blocks/door_spruce_lower.png b/website/blocks/door_spruce_lower.png deleted file mode 100644 index 5faa6e7..0000000 Binary files a/website/blocks/door_spruce_lower.png and /dev/null differ diff --git a/website/blocks/door_spruce_upper.png b/website/blocks/door_spruce_upper.png deleted file mode 100644 index 38e2717..0000000 Binary files a/website/blocks/door_spruce_upper.png and /dev/null differ diff --git a/website/blocks/door_wood_lower.png b/website/blocks/door_wood_lower.png deleted file mode 100644 index cc61731..0000000 Binary files a/website/blocks/door_wood_lower.png and /dev/null differ diff --git a/website/blocks/door_wood_upper.png b/website/blocks/door_wood_upper.png deleted file mode 100644 index 93319d5..0000000 Binary files a/website/blocks/door_wood_upper.png and /dev/null differ diff --git a/website/blocks/double_plant_fern_bottom.png b/website/blocks/double_plant_fern_bottom.png deleted file mode 100644 index 6a5fa6a..0000000 Binary files a/website/blocks/double_plant_fern_bottom.png and /dev/null differ diff --git a/website/blocks/double_plant_fern_top.png b/website/blocks/double_plant_fern_top.png deleted file mode 100644 index 393144c..0000000 Binary files a/website/blocks/double_plant_fern_top.png and /dev/null differ diff --git a/website/blocks/double_plant_grass_bottom.png b/website/blocks/double_plant_grass_bottom.png deleted file mode 100644 index 5ac15b4..0000000 Binary files a/website/blocks/double_plant_grass_bottom.png and /dev/null differ diff --git a/website/blocks/double_plant_grass_top.png b/website/blocks/double_plant_grass_top.png deleted file mode 100644 index 1ceb3de..0000000 Binary files a/website/blocks/double_plant_grass_top.png and /dev/null differ diff --git a/website/blocks/double_plant_paeonia_bottom.png b/website/blocks/double_plant_paeonia_bottom.png deleted file mode 100644 index 21285f5..0000000 Binary files a/website/blocks/double_plant_paeonia_bottom.png and /dev/null differ diff --git a/website/blocks/double_plant_paeonia_top.png b/website/blocks/double_plant_paeonia_top.png deleted file mode 100644 index bc68241..0000000 Binary files a/website/blocks/double_plant_paeonia_top.png and /dev/null differ diff --git a/website/blocks/double_plant_rose_bottom.png b/website/blocks/double_plant_rose_bottom.png deleted file mode 100644 index 4d59f44..0000000 Binary files a/website/blocks/double_plant_rose_bottom.png and /dev/null differ diff --git a/website/blocks/double_plant_rose_top.png b/website/blocks/double_plant_rose_top.png deleted file mode 100644 index 028aa80..0000000 Binary files a/website/blocks/double_plant_rose_top.png and /dev/null differ diff --git a/website/blocks/double_plant_sunflower_back.png b/website/blocks/double_plant_sunflower_back.png deleted file mode 100644 index d488923..0000000 Binary files a/website/blocks/double_plant_sunflower_back.png and /dev/null differ diff --git a/website/blocks/double_plant_sunflower_bottom.png b/website/blocks/double_plant_sunflower_bottom.png deleted file mode 100644 index 8a12ebe..0000000 Binary files a/website/blocks/double_plant_sunflower_bottom.png and /dev/null differ diff --git a/website/blocks/double_plant_sunflower_front.png b/website/blocks/double_plant_sunflower_front.png deleted file mode 100644 index 699e34f..0000000 Binary files a/website/blocks/double_plant_sunflower_front.png and /dev/null differ diff --git a/website/blocks/double_plant_sunflower_top.png b/website/blocks/double_plant_sunflower_top.png deleted file mode 100644 index 4d14203..0000000 Binary files a/website/blocks/double_plant_sunflower_top.png and /dev/null differ diff --git a/website/blocks/double_plant_syringa_bottom.png b/website/blocks/double_plant_syringa_bottom.png deleted file mode 100644 index 5b880b5..0000000 Binary files a/website/blocks/double_plant_syringa_bottom.png and /dev/null differ diff --git a/website/blocks/double_plant_syringa_top.png b/website/blocks/double_plant_syringa_top.png deleted file mode 100644 index b00751f..0000000 Binary files a/website/blocks/double_plant_syringa_top.png and /dev/null differ diff --git a/website/blocks/dragon_egg.png b/website/blocks/dragon_egg.png deleted file mode 100644 index 02af123..0000000 Binary files a/website/blocks/dragon_egg.png and /dev/null differ diff --git a/website/blocks/dropper_front_horizontal.png b/website/blocks/dropper_front_horizontal.png deleted file mode 100644 index 7d5f260..0000000 Binary files a/website/blocks/dropper_front_horizontal.png and /dev/null differ diff --git a/website/blocks/dropper_front_vertical.png b/website/blocks/dropper_front_vertical.png deleted file mode 100644 index 68a56c8..0000000 Binary files a/website/blocks/dropper_front_vertical.png and /dev/null differ diff --git a/website/blocks/emerald_block.png b/website/blocks/emerald_block.png deleted file mode 100644 index dc214ee..0000000 Binary files a/website/blocks/emerald_block.png and /dev/null differ diff --git a/website/blocks/emerald_ore.png b/website/blocks/emerald_ore.png deleted file mode 100644 index a26c35f..0000000 Binary files a/website/blocks/emerald_ore.png and /dev/null differ diff --git a/website/blocks/enchanting_table_bottom.png b/website/blocks/enchanting_table_bottom.png deleted file mode 100644 index 0f492aa..0000000 Binary files a/website/blocks/enchanting_table_bottom.png and /dev/null differ diff --git a/website/blocks/enchanting_table_side.png b/website/blocks/enchanting_table_side.png deleted file mode 100644 index f2f4614..0000000 Binary files a/website/blocks/enchanting_table_side.png and /dev/null differ diff --git a/website/blocks/enchanting_table_top.png b/website/blocks/enchanting_table_top.png deleted file mode 100644 index 0d5f68f..0000000 Binary files a/website/blocks/enchanting_table_top.png and /dev/null differ diff --git a/website/blocks/end_stone.png b/website/blocks/end_stone.png deleted file mode 100644 index c2a91e3..0000000 Binary files a/website/blocks/end_stone.png and /dev/null differ diff --git a/website/blocks/endframe_eye.png b/website/blocks/endframe_eye.png deleted file mode 100644 index afa1d5d..0000000 Binary files a/website/blocks/endframe_eye.png and /dev/null differ diff --git a/website/blocks/endframe_side.png b/website/blocks/endframe_side.png deleted file mode 100644 index e6cb567..0000000 Binary files a/website/blocks/endframe_side.png and /dev/null differ diff --git a/website/blocks/endframe_top.png b/website/blocks/endframe_top.png deleted file mode 100644 index 35215a5..0000000 Binary files a/website/blocks/endframe_top.png and /dev/null differ diff --git a/website/blocks/farmland_dry.png b/website/blocks/farmland_dry.png deleted file mode 100644 index d03a0f4..0000000 Binary files a/website/blocks/farmland_dry.png and /dev/null differ diff --git a/website/blocks/farmland_wet.png b/website/blocks/farmland_wet.png deleted file mode 100644 index f8d460d..0000000 Binary files a/website/blocks/farmland_wet.png and /dev/null differ diff --git a/website/blocks/fern.png b/website/blocks/fern.png deleted file mode 100644 index fd76950..0000000 Binary files a/website/blocks/fern.png and /dev/null differ diff --git a/website/blocks/fire_layer_0.png b/website/blocks/fire_layer_0.png deleted file mode 100644 index cf8910f..0000000 Binary files a/website/blocks/fire_layer_0.png and /dev/null differ diff --git a/website/blocks/fire_layer_0.png.mcmeta b/website/blocks/fire_layer_0.png.mcmeta deleted file mode 100644 index 7644671..0000000 --- a/website/blocks/fire_layer_0.png.mcmeta +++ /dev/null @@ -1,38 +0,0 @@ -{ - "animation": { - "frames": [ - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23, - 24, - 25, - 26, - 27, - 28, - 29, - 30, - 31, - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15 - ] - } -} diff --git a/website/blocks/fire_layer_1.png b/website/blocks/fire_layer_1.png deleted file mode 100644 index 6db92ac..0000000 Binary files a/website/blocks/fire_layer_1.png and /dev/null differ diff --git a/website/blocks/fire_layer_1.png.mcmeta b/website/blocks/fire_layer_1.png.mcmeta deleted file mode 100644 index 4f0718a..0000000 --- a/website/blocks/fire_layer_1.png.mcmeta +++ /dev/null @@ -1,3 +0,0 @@ -{ - "animation": {} -} \ No newline at end of file diff --git a/website/blocks/flower_allium.png b/website/blocks/flower_allium.png deleted file mode 100644 index b7b5a45..0000000 Binary files a/website/blocks/flower_allium.png and /dev/null differ diff --git a/website/blocks/flower_blue_orchid.png b/website/blocks/flower_blue_orchid.png deleted file mode 100644 index 51d7fd9..0000000 Binary files a/website/blocks/flower_blue_orchid.png and /dev/null differ diff --git a/website/blocks/flower_dandelion.png b/website/blocks/flower_dandelion.png deleted file mode 100644 index 873e3f5..0000000 Binary files a/website/blocks/flower_dandelion.png and /dev/null differ diff --git a/website/blocks/flower_houstonia.png b/website/blocks/flower_houstonia.png deleted file mode 100644 index 2f9127d..0000000 Binary files a/website/blocks/flower_houstonia.png and /dev/null differ diff --git a/website/blocks/flower_oxeye_daisy.png b/website/blocks/flower_oxeye_daisy.png deleted file mode 100644 index 6d48913..0000000 Binary files a/website/blocks/flower_oxeye_daisy.png and /dev/null differ diff --git a/website/blocks/flower_paeonia.png b/website/blocks/flower_paeonia.png deleted file mode 100644 index 01a92ee..0000000 Binary files a/website/blocks/flower_paeonia.png and /dev/null differ diff --git a/website/blocks/flower_pot.png b/website/blocks/flower_pot.png deleted file mode 100644 index 09c2523..0000000 Binary files a/website/blocks/flower_pot.png and /dev/null differ diff --git a/website/blocks/flower_rose.png b/website/blocks/flower_rose.png deleted file mode 100644 index 895d78f..0000000 Binary files a/website/blocks/flower_rose.png and /dev/null differ diff --git a/website/blocks/flower_tulip_orange.png b/website/blocks/flower_tulip_orange.png deleted file mode 100644 index 6715a62..0000000 Binary files a/website/blocks/flower_tulip_orange.png and /dev/null differ diff --git a/website/blocks/flower_tulip_pink.png b/website/blocks/flower_tulip_pink.png deleted file mode 100644 index a757bf5..0000000 Binary files a/website/blocks/flower_tulip_pink.png and /dev/null differ diff --git a/website/blocks/flower_tulip_red.png b/website/blocks/flower_tulip_red.png deleted file mode 100644 index 3048b63..0000000 Binary files a/website/blocks/flower_tulip_red.png and /dev/null differ diff --git a/website/blocks/flower_tulip_white.png b/website/blocks/flower_tulip_white.png deleted file mode 100644 index 6aa12a9..0000000 Binary files a/website/blocks/flower_tulip_white.png and /dev/null differ diff --git a/website/blocks/furnace_front_off.png b/website/blocks/furnace_front_off.png deleted file mode 100644 index 0570c3a..0000000 Binary files a/website/blocks/furnace_front_off.png and /dev/null differ diff --git a/website/blocks/furnace_front_on.png b/website/blocks/furnace_front_on.png deleted file mode 100644 index 92c89f3..0000000 Binary files a/website/blocks/furnace_front_on.png and /dev/null differ diff --git a/website/blocks/furnace_side.png b/website/blocks/furnace_side.png deleted file mode 100644 index 115f73d..0000000 Binary files a/website/blocks/furnace_side.png and /dev/null differ diff --git a/website/blocks/furnace_top.png b/website/blocks/furnace_top.png deleted file mode 100644 index a3a5a08..0000000 Binary files a/website/blocks/furnace_top.png and /dev/null differ diff --git a/website/blocks/glass.png b/website/blocks/glass.png deleted file mode 100644 index acadb01..0000000 Binary files a/website/blocks/glass.png and /dev/null differ diff --git a/website/blocks/glass_black.png b/website/blocks/glass_black.png deleted file mode 100644 index 06f3427..0000000 Binary files a/website/blocks/glass_black.png and /dev/null differ diff --git a/website/blocks/glass_blue.png b/website/blocks/glass_blue.png deleted file mode 100644 index 38885de..0000000 Binary files a/website/blocks/glass_blue.png and /dev/null differ diff --git a/website/blocks/glass_brown.png b/website/blocks/glass_brown.png deleted file mode 100644 index 259b61c..0000000 Binary files a/website/blocks/glass_brown.png and /dev/null differ diff --git a/website/blocks/glass_cyan.png b/website/blocks/glass_cyan.png deleted file mode 100644 index d30caa4..0000000 Binary files a/website/blocks/glass_cyan.png and /dev/null differ diff --git a/website/blocks/glass_gray.png b/website/blocks/glass_gray.png deleted file mode 100644 index 3f07a5d..0000000 Binary files a/website/blocks/glass_gray.png and /dev/null differ diff --git a/website/blocks/glass_green.png b/website/blocks/glass_green.png deleted file mode 100644 index 7c1f4e6..0000000 Binary files a/website/blocks/glass_green.png and /dev/null differ diff --git a/website/blocks/glass_light_blue.png b/website/blocks/glass_light_blue.png deleted file mode 100644 index b62703d..0000000 Binary files a/website/blocks/glass_light_blue.png and /dev/null differ diff --git a/website/blocks/glass_lime.png b/website/blocks/glass_lime.png deleted file mode 100644 index f1d3c46..0000000 Binary files a/website/blocks/glass_lime.png and /dev/null differ diff --git a/website/blocks/glass_magenta.png b/website/blocks/glass_magenta.png deleted file mode 100644 index 5cd9945..0000000 Binary files a/website/blocks/glass_magenta.png and /dev/null differ diff --git a/website/blocks/glass_orange.png b/website/blocks/glass_orange.png deleted file mode 100644 index 3a29e31..0000000 Binary files a/website/blocks/glass_orange.png and /dev/null differ diff --git a/website/blocks/glass_pane_top.png b/website/blocks/glass_pane_top.png deleted file mode 100644 index 02de587..0000000 Binary files a/website/blocks/glass_pane_top.png and /dev/null differ diff --git a/website/blocks/glass_pane_top_black.png b/website/blocks/glass_pane_top_black.png deleted file mode 100644 index 43d60c5..0000000 Binary files a/website/blocks/glass_pane_top_black.png and /dev/null differ diff --git a/website/blocks/glass_pane_top_blue.png b/website/blocks/glass_pane_top_blue.png deleted file mode 100644 index 55c614f..0000000 Binary files a/website/blocks/glass_pane_top_blue.png and /dev/null differ diff --git a/website/blocks/glass_pane_top_brown.png b/website/blocks/glass_pane_top_brown.png deleted file mode 100644 index cbd791a..0000000 Binary files a/website/blocks/glass_pane_top_brown.png and /dev/null differ diff --git a/website/blocks/glass_pane_top_cyan.png b/website/blocks/glass_pane_top_cyan.png deleted file mode 100644 index 9a34b84..0000000 Binary files a/website/blocks/glass_pane_top_cyan.png and /dev/null differ diff --git a/website/blocks/glass_pane_top_gray.png b/website/blocks/glass_pane_top_gray.png deleted file mode 100644 index bb06114..0000000 Binary files a/website/blocks/glass_pane_top_gray.png and /dev/null differ diff --git a/website/blocks/glass_pane_top_green.png b/website/blocks/glass_pane_top_green.png deleted file mode 100644 index a7d9fc7..0000000 Binary files a/website/blocks/glass_pane_top_green.png and /dev/null differ diff --git a/website/blocks/glass_pane_top_light_blue.png b/website/blocks/glass_pane_top_light_blue.png deleted file mode 100644 index 6a0e661..0000000 Binary files a/website/blocks/glass_pane_top_light_blue.png and /dev/null differ diff --git a/website/blocks/glass_pane_top_lime.png b/website/blocks/glass_pane_top_lime.png deleted file mode 100644 index 0607d75..0000000 Binary files a/website/blocks/glass_pane_top_lime.png and /dev/null differ diff --git a/website/blocks/glass_pane_top_magenta.png b/website/blocks/glass_pane_top_magenta.png deleted file mode 100644 index 5419e52..0000000 Binary files a/website/blocks/glass_pane_top_magenta.png and /dev/null differ diff --git a/website/blocks/glass_pane_top_orange.png b/website/blocks/glass_pane_top_orange.png deleted file mode 100644 index 2866571..0000000 Binary files a/website/blocks/glass_pane_top_orange.png and /dev/null differ diff --git a/website/blocks/glass_pane_top_pink.png b/website/blocks/glass_pane_top_pink.png deleted file mode 100644 index 6b6cd76..0000000 Binary files a/website/blocks/glass_pane_top_pink.png and /dev/null differ diff --git a/website/blocks/glass_pane_top_purple.png b/website/blocks/glass_pane_top_purple.png deleted file mode 100644 index 23e208e..0000000 Binary files a/website/blocks/glass_pane_top_purple.png and /dev/null differ diff --git a/website/blocks/glass_pane_top_red.png b/website/blocks/glass_pane_top_red.png deleted file mode 100644 index 22b69db..0000000 Binary files a/website/blocks/glass_pane_top_red.png and /dev/null differ diff --git a/website/blocks/glass_pane_top_silver.png b/website/blocks/glass_pane_top_silver.png deleted file mode 100644 index f226ecc..0000000 Binary files a/website/blocks/glass_pane_top_silver.png and /dev/null differ diff --git a/website/blocks/glass_pane_top_white.png b/website/blocks/glass_pane_top_white.png deleted file mode 100644 index 9a273c3..0000000 Binary files a/website/blocks/glass_pane_top_white.png and /dev/null differ diff --git a/website/blocks/glass_pane_top_yellow.png b/website/blocks/glass_pane_top_yellow.png deleted file mode 100644 index 1c8580f..0000000 Binary files a/website/blocks/glass_pane_top_yellow.png and /dev/null differ diff --git a/website/blocks/glass_pink.png b/website/blocks/glass_pink.png deleted file mode 100644 index 42d8739..0000000 Binary files a/website/blocks/glass_pink.png and /dev/null differ diff --git a/website/blocks/glass_purple.png b/website/blocks/glass_purple.png deleted file mode 100644 index fcae3d2..0000000 Binary files a/website/blocks/glass_purple.png and /dev/null differ diff --git a/website/blocks/glass_red.png b/website/blocks/glass_red.png deleted file mode 100644 index db4c5eb..0000000 Binary files a/website/blocks/glass_red.png and /dev/null differ diff --git a/website/blocks/glass_silver.png b/website/blocks/glass_silver.png deleted file mode 100644 index 8461664..0000000 Binary files a/website/blocks/glass_silver.png and /dev/null differ diff --git a/website/blocks/glass_white.png b/website/blocks/glass_white.png deleted file mode 100644 index 696c5aa..0000000 Binary files a/website/blocks/glass_white.png and /dev/null differ diff --git a/website/blocks/glass_yellow.png b/website/blocks/glass_yellow.png deleted file mode 100644 index 8e48e12..0000000 Binary files a/website/blocks/glass_yellow.png and /dev/null differ diff --git a/website/blocks/glowstone.png b/website/blocks/glowstone.png deleted file mode 100644 index c7253b3..0000000 Binary files a/website/blocks/glowstone.png and /dev/null differ diff --git a/website/blocks/gold_block.png b/website/blocks/gold_block.png deleted file mode 100644 index 174002e..0000000 Binary files a/website/blocks/gold_block.png and /dev/null differ diff --git a/website/blocks/gold_ore.png b/website/blocks/gold_ore.png deleted file mode 100644 index b1a7a55..0000000 Binary files a/website/blocks/gold_ore.png and /dev/null differ diff --git a/website/blocks/grass_side.png b/website/blocks/grass_side.png deleted file mode 100644 index a4975e5..0000000 Binary files a/website/blocks/grass_side.png and /dev/null differ diff --git a/website/blocks/grass_side_overlay.png b/website/blocks/grass_side_overlay.png deleted file mode 100644 index fc3fa9d..0000000 Binary files a/website/blocks/grass_side_overlay.png and /dev/null differ diff --git a/website/blocks/grass_side_snowed.png b/website/blocks/grass_side_snowed.png deleted file mode 100644 index 41f6197..0000000 Binary files a/website/blocks/grass_side_snowed.png and /dev/null differ diff --git a/website/blocks/grass_top.png b/website/blocks/grass_top.png deleted file mode 100644 index eaa7e45..0000000 Binary files a/website/blocks/grass_top.png and /dev/null differ diff --git a/website/blocks/gravel.png b/website/blocks/gravel.png deleted file mode 100644 index 388e5c5..0000000 Binary files a/website/blocks/gravel.png and /dev/null differ diff --git a/website/blocks/hardened_clay.png b/website/blocks/hardened_clay.png deleted file mode 100644 index 2446380..0000000 Binary files a/website/blocks/hardened_clay.png and /dev/null differ diff --git a/website/blocks/hardened_clay_stained_black.png b/website/blocks/hardened_clay_stained_black.png deleted file mode 100644 index 59da22c..0000000 Binary files a/website/blocks/hardened_clay_stained_black.png and /dev/null differ diff --git a/website/blocks/hardened_clay_stained_blue.png b/website/blocks/hardened_clay_stained_blue.png deleted file mode 100644 index 7e38e27..0000000 Binary files a/website/blocks/hardened_clay_stained_blue.png and /dev/null differ diff --git a/website/blocks/hardened_clay_stained_brown.png b/website/blocks/hardened_clay_stained_brown.png deleted file mode 100644 index f81745f..0000000 Binary files a/website/blocks/hardened_clay_stained_brown.png and /dev/null differ diff --git a/website/blocks/hardened_clay_stained_cyan.png b/website/blocks/hardened_clay_stained_cyan.png deleted file mode 100644 index b05428c..0000000 Binary files a/website/blocks/hardened_clay_stained_cyan.png and /dev/null differ diff --git a/website/blocks/hardened_clay_stained_gray.png b/website/blocks/hardened_clay_stained_gray.png deleted file mode 100644 index 8f86904..0000000 Binary files a/website/blocks/hardened_clay_stained_gray.png and /dev/null differ diff --git a/website/blocks/hardened_clay_stained_green.png b/website/blocks/hardened_clay_stained_green.png deleted file mode 100644 index e89162e..0000000 Binary files a/website/blocks/hardened_clay_stained_green.png and /dev/null differ diff --git a/website/blocks/hardened_clay_stained_light_blue.png b/website/blocks/hardened_clay_stained_light_blue.png deleted file mode 100644 index 3d9ebea..0000000 Binary files a/website/blocks/hardened_clay_stained_light_blue.png and /dev/null differ diff --git a/website/blocks/hardened_clay_stained_lime.png b/website/blocks/hardened_clay_stained_lime.png deleted file mode 100644 index b459a0b..0000000 Binary files a/website/blocks/hardened_clay_stained_lime.png and /dev/null differ diff --git a/website/blocks/hardened_clay_stained_magenta.png b/website/blocks/hardened_clay_stained_magenta.png deleted file mode 100644 index 9663106..0000000 Binary files a/website/blocks/hardened_clay_stained_magenta.png and /dev/null differ diff --git a/website/blocks/hardened_clay_stained_orange.png b/website/blocks/hardened_clay_stained_orange.png deleted file mode 100644 index 40929db..0000000 Binary files a/website/blocks/hardened_clay_stained_orange.png and /dev/null differ diff --git a/website/blocks/hardened_clay_stained_pink.png b/website/blocks/hardened_clay_stained_pink.png deleted file mode 100644 index c21c0aa..0000000 Binary files a/website/blocks/hardened_clay_stained_pink.png and /dev/null differ diff --git a/website/blocks/hardened_clay_stained_purple.png b/website/blocks/hardened_clay_stained_purple.png deleted file mode 100644 index edece94..0000000 Binary files a/website/blocks/hardened_clay_stained_purple.png and /dev/null differ diff --git a/website/blocks/hardened_clay_stained_red.png b/website/blocks/hardened_clay_stained_red.png deleted file mode 100644 index 6561d12..0000000 Binary files a/website/blocks/hardened_clay_stained_red.png and /dev/null differ diff --git a/website/blocks/hardened_clay_stained_silver.png b/website/blocks/hardened_clay_stained_silver.png deleted file mode 100644 index eae07f2..0000000 Binary files a/website/blocks/hardened_clay_stained_silver.png and /dev/null differ diff --git a/website/blocks/hardened_clay_stained_white.png b/website/blocks/hardened_clay_stained_white.png deleted file mode 100644 index 8066af0..0000000 Binary files a/website/blocks/hardened_clay_stained_white.png and /dev/null differ diff --git a/website/blocks/hardened_clay_stained_yellow.png b/website/blocks/hardened_clay_stained_yellow.png deleted file mode 100644 index 5da4687..0000000 Binary files a/website/blocks/hardened_clay_stained_yellow.png and /dev/null differ diff --git a/website/blocks/hay_block_side.png b/website/blocks/hay_block_side.png deleted file mode 100644 index a2b32db..0000000 Binary files a/website/blocks/hay_block_side.png and /dev/null differ diff --git a/website/blocks/hay_block_top.png b/website/blocks/hay_block_top.png deleted file mode 100644 index 1d35593..0000000 Binary files a/website/blocks/hay_block_top.png and /dev/null differ diff --git a/website/blocks/hopper_inside.png b/website/blocks/hopper_inside.png deleted file mode 100644 index 24e8eae..0000000 Binary files a/website/blocks/hopper_inside.png and /dev/null differ diff --git a/website/blocks/hopper_outside.png b/website/blocks/hopper_outside.png deleted file mode 100644 index 50ed8d5..0000000 Binary files a/website/blocks/hopper_outside.png and /dev/null differ diff --git a/website/blocks/hopper_top.png b/website/blocks/hopper_top.png deleted file mode 100644 index e0dbe96..0000000 Binary files a/website/blocks/hopper_top.png and /dev/null differ diff --git a/website/blocks/ice.png b/website/blocks/ice.png deleted file mode 100644 index ac946e9..0000000 Binary files a/website/blocks/ice.png and /dev/null differ diff --git a/website/blocks/ice_packed.png b/website/blocks/ice_packed.png deleted file mode 100644 index 50f0f34..0000000 Binary files a/website/blocks/ice_packed.png and /dev/null differ diff --git a/website/blocks/iron_bars.png b/website/blocks/iron_bars.png deleted file mode 100644 index 732807f..0000000 Binary files a/website/blocks/iron_bars.png and /dev/null differ diff --git a/website/blocks/iron_block.png b/website/blocks/iron_block.png deleted file mode 100644 index 7816799..0000000 Binary files a/website/blocks/iron_block.png and /dev/null differ diff --git a/website/blocks/iron_ore.png b/website/blocks/iron_ore.png deleted file mode 100644 index 250d8bb..0000000 Binary files a/website/blocks/iron_ore.png and /dev/null differ diff --git a/website/blocks/iron_trapdoor.png b/website/blocks/iron_trapdoor.png deleted file mode 100644 index d3c974c..0000000 Binary files a/website/blocks/iron_trapdoor.png and /dev/null differ diff --git a/website/blocks/itemframe_background.png b/website/blocks/itemframe_background.png deleted file mode 100644 index b40ad65..0000000 Binary files a/website/blocks/itemframe_background.png and /dev/null differ diff --git a/website/blocks/jukebox_side.png b/website/blocks/jukebox_side.png deleted file mode 100644 index a3c27c1..0000000 Binary files a/website/blocks/jukebox_side.png and /dev/null differ diff --git a/website/blocks/jukebox_top.png b/website/blocks/jukebox_top.png deleted file mode 100644 index 92ddb15..0000000 Binary files a/website/blocks/jukebox_top.png and /dev/null differ diff --git a/website/blocks/ladder.png b/website/blocks/ladder.png deleted file mode 100644 index e2ec5f2..0000000 Binary files a/website/blocks/ladder.png and /dev/null differ diff --git a/website/blocks/lapis_block.png b/website/blocks/lapis_block.png deleted file mode 100644 index 0271489..0000000 Binary files a/website/blocks/lapis_block.png and /dev/null differ diff --git a/website/blocks/lapis_ore.png b/website/blocks/lapis_ore.png deleted file mode 100644 index 6144236..0000000 Binary files a/website/blocks/lapis_ore.png and /dev/null differ diff --git a/website/blocks/lava_flow.png b/website/blocks/lava_flow.png deleted file mode 100644 index af07f91..0000000 Binary files a/website/blocks/lava_flow.png and /dev/null differ diff --git a/website/blocks/lava_flow.png.mcmeta b/website/blocks/lava_flow.png.mcmeta deleted file mode 100644 index 8e55e43..0000000 --- a/website/blocks/lava_flow.png.mcmeta +++ /dev/null @@ -1,5 +0,0 @@ -{ - "animation": { - "frametime": 3 - } -} diff --git a/website/blocks/lava_still.png b/website/blocks/lava_still.png deleted file mode 100644 index 78bb29d..0000000 Binary files a/website/blocks/lava_still.png and /dev/null differ diff --git a/website/blocks/lava_still.png.mcmeta b/website/blocks/lava_still.png.mcmeta deleted file mode 100644 index 7ceb363..0000000 --- a/website/blocks/lava_still.png.mcmeta +++ /dev/null @@ -1,45 +0,0 @@ -{ - "animation": { - "frametime": 2, - "frames": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 18, - 17, - 16, - 15, - 14, - 13, - 12, - 11, - 10, - 9, - 8, - 7, - 6, - 5, - 4, - 3, - 2, - 1 - ] - } -} \ No newline at end of file diff --git a/website/blocks/leaves_acacia.png b/website/blocks/leaves_acacia.png deleted file mode 100644 index d54eb17..0000000 Binary files a/website/blocks/leaves_acacia.png and /dev/null differ diff --git a/website/blocks/leaves_big_oak.png b/website/blocks/leaves_big_oak.png deleted file mode 100644 index a6773af..0000000 Binary files a/website/blocks/leaves_big_oak.png and /dev/null differ diff --git a/website/blocks/leaves_birch.png b/website/blocks/leaves_birch.png deleted file mode 100644 index a6773af..0000000 Binary files a/website/blocks/leaves_birch.png and /dev/null differ diff --git a/website/blocks/leaves_jungle.png b/website/blocks/leaves_jungle.png deleted file mode 100644 index e0cb935..0000000 Binary files a/website/blocks/leaves_jungle.png and /dev/null differ diff --git a/website/blocks/leaves_oak.png b/website/blocks/leaves_oak.png deleted file mode 100644 index a6773af..0000000 Binary files a/website/blocks/leaves_oak.png and /dev/null differ diff --git a/website/blocks/leaves_spruce.png b/website/blocks/leaves_spruce.png deleted file mode 100644 index 602eab8..0000000 Binary files a/website/blocks/leaves_spruce.png and /dev/null differ diff --git a/website/blocks/lever.png b/website/blocks/lever.png deleted file mode 100644 index 051187f..0000000 Binary files a/website/blocks/lever.png and /dev/null differ diff --git a/website/blocks/log_acacia.png b/website/blocks/log_acacia.png deleted file mode 100644 index d221210..0000000 Binary files a/website/blocks/log_acacia.png and /dev/null differ diff --git a/website/blocks/log_acacia_top.png b/website/blocks/log_acacia_top.png deleted file mode 100644 index 3d44878..0000000 Binary files a/website/blocks/log_acacia_top.png and /dev/null differ diff --git a/website/blocks/log_big_oak.png b/website/blocks/log_big_oak.png deleted file mode 100644 index d4da03e..0000000 Binary files a/website/blocks/log_big_oak.png and /dev/null differ diff --git a/website/blocks/log_big_oak_top.png b/website/blocks/log_big_oak_top.png deleted file mode 100644 index 99137b6..0000000 Binary files a/website/blocks/log_big_oak_top.png and /dev/null differ diff --git a/website/blocks/log_birch.png b/website/blocks/log_birch.png deleted file mode 100644 index bfb209d..0000000 Binary files a/website/blocks/log_birch.png and /dev/null differ diff --git a/website/blocks/log_birch_top.png b/website/blocks/log_birch_top.png deleted file mode 100644 index f9b94f4..0000000 Binary files a/website/blocks/log_birch_top.png and /dev/null differ diff --git a/website/blocks/log_jungle.png b/website/blocks/log_jungle.png deleted file mode 100644 index 0b7120a..0000000 Binary files a/website/blocks/log_jungle.png and /dev/null differ diff --git a/website/blocks/log_jungle_top.png b/website/blocks/log_jungle_top.png deleted file mode 100644 index 26b0361..0000000 Binary files a/website/blocks/log_jungle_top.png and /dev/null differ diff --git a/website/blocks/log_oak.png b/website/blocks/log_oak.png deleted file mode 100644 index 914cb5f..0000000 Binary files a/website/blocks/log_oak.png and /dev/null differ diff --git a/website/blocks/log_oak_top.png b/website/blocks/log_oak_top.png deleted file mode 100644 index 7a44e77..0000000 Binary files a/website/blocks/log_oak_top.png and /dev/null differ diff --git a/website/blocks/log_spruce.png b/website/blocks/log_spruce.png deleted file mode 100644 index dc1aa2f..0000000 Binary files a/website/blocks/log_spruce.png and /dev/null differ diff --git a/website/blocks/log_spruce_top.png b/website/blocks/log_spruce_top.png deleted file mode 100644 index 280c64e..0000000 Binary files a/website/blocks/log_spruce_top.png and /dev/null differ diff --git a/website/blocks/melon_side.png b/website/blocks/melon_side.png deleted file mode 100644 index ec7b430..0000000 Binary files a/website/blocks/melon_side.png and /dev/null differ diff --git a/website/blocks/melon_stem_connected.png b/website/blocks/melon_stem_connected.png deleted file mode 100644 index 6a5c10e..0000000 Binary files a/website/blocks/melon_stem_connected.png and /dev/null differ diff --git a/website/blocks/melon_stem_disconnected.png b/website/blocks/melon_stem_disconnected.png deleted file mode 100644 index 38065ef..0000000 Binary files a/website/blocks/melon_stem_disconnected.png and /dev/null differ diff --git a/website/blocks/melon_top.png b/website/blocks/melon_top.png deleted file mode 100644 index 65cf169..0000000 Binary files a/website/blocks/melon_top.png and /dev/null differ diff --git a/website/blocks/mob_spawner.png b/website/blocks/mob_spawner.png deleted file mode 100644 index 7d55217..0000000 Binary files a/website/blocks/mob_spawner.png and /dev/null differ diff --git a/website/blocks/mushroom_block_inside.png b/website/blocks/mushroom_block_inside.png deleted file mode 100644 index f0e7a04..0000000 Binary files a/website/blocks/mushroom_block_inside.png and /dev/null differ diff --git a/website/blocks/mushroom_block_skin_brown.png b/website/blocks/mushroom_block_skin_brown.png deleted file mode 100644 index 1f52ba8..0000000 Binary files a/website/blocks/mushroom_block_skin_brown.png and /dev/null differ diff --git a/website/blocks/mushroom_block_skin_red.png b/website/blocks/mushroom_block_skin_red.png deleted file mode 100644 index 66cf12c..0000000 Binary files a/website/blocks/mushroom_block_skin_red.png and /dev/null differ diff --git a/website/blocks/mushroom_block_skin_stem.png b/website/blocks/mushroom_block_skin_stem.png deleted file mode 100644 index 83c0840..0000000 Binary files a/website/blocks/mushroom_block_skin_stem.png and /dev/null differ diff --git a/website/blocks/mushroom_brown.png b/website/blocks/mushroom_brown.png deleted file mode 100644 index bf33d34..0000000 Binary files a/website/blocks/mushroom_brown.png and /dev/null differ diff --git a/website/blocks/mushroom_red.png b/website/blocks/mushroom_red.png deleted file mode 100644 index 1b332b7..0000000 Binary files a/website/blocks/mushroom_red.png and /dev/null differ diff --git a/website/blocks/mycelium_side.png b/website/blocks/mycelium_side.png deleted file mode 100644 index 5547425..0000000 Binary files a/website/blocks/mycelium_side.png and /dev/null differ diff --git a/website/blocks/mycelium_top.png b/website/blocks/mycelium_top.png deleted file mode 100644 index 088a825..0000000 Binary files a/website/blocks/mycelium_top.png and /dev/null differ diff --git a/website/blocks/nether_brick.png b/website/blocks/nether_brick.png deleted file mode 100644 index caaf66f..0000000 Binary files a/website/blocks/nether_brick.png and /dev/null differ diff --git a/website/blocks/nether_wart_stage_0.png b/website/blocks/nether_wart_stage_0.png deleted file mode 100644 index 514a95b..0000000 Binary files a/website/blocks/nether_wart_stage_0.png and /dev/null differ diff --git a/website/blocks/nether_wart_stage_1.png b/website/blocks/nether_wart_stage_1.png deleted file mode 100644 index b4ad0d1..0000000 Binary files a/website/blocks/nether_wart_stage_1.png and /dev/null differ diff --git a/website/blocks/nether_wart_stage_2.png b/website/blocks/nether_wart_stage_2.png deleted file mode 100644 index b9b6743..0000000 Binary files a/website/blocks/nether_wart_stage_2.png and /dev/null differ diff --git a/website/blocks/netherrack.png b/website/blocks/netherrack.png deleted file mode 100644 index 88129c4..0000000 Binary files a/website/blocks/netherrack.png and /dev/null differ diff --git a/website/blocks/noteblock.png b/website/blocks/noteblock.png deleted file mode 100644 index a3c27c1..0000000 Binary files a/website/blocks/noteblock.png and /dev/null differ diff --git a/website/blocks/obsidian.png b/website/blocks/obsidian.png deleted file mode 100644 index ff0a683..0000000 Binary files a/website/blocks/obsidian.png and /dev/null differ diff --git a/website/blocks/piston_bottom.png b/website/blocks/piston_bottom.png deleted file mode 100644 index a3a5a08..0000000 Binary files a/website/blocks/piston_bottom.png and /dev/null differ diff --git a/website/blocks/piston_inner.png b/website/blocks/piston_inner.png deleted file mode 100644 index 1043929..0000000 Binary files a/website/blocks/piston_inner.png and /dev/null differ diff --git a/website/blocks/piston_side.png b/website/blocks/piston_side.png deleted file mode 100644 index 634f54a..0000000 Binary files a/website/blocks/piston_side.png and /dev/null differ diff --git a/website/blocks/piston_top_normal.png b/website/blocks/piston_top_normal.png deleted file mode 100644 index eeaadab..0000000 Binary files a/website/blocks/piston_top_normal.png and /dev/null differ diff --git a/website/blocks/piston_top_sticky.png b/website/blocks/piston_top_sticky.png deleted file mode 100644 index 6ddd4ad..0000000 Binary files a/website/blocks/piston_top_sticky.png and /dev/null differ diff --git a/website/blocks/planks_acacia.png b/website/blocks/planks_acacia.png deleted file mode 100644 index 6858c51..0000000 Binary files a/website/blocks/planks_acacia.png and /dev/null differ diff --git a/website/blocks/planks_big_oak.png b/website/blocks/planks_big_oak.png deleted file mode 100644 index e3fd4ea..0000000 Binary files a/website/blocks/planks_big_oak.png and /dev/null differ diff --git a/website/blocks/planks_birch.png b/website/blocks/planks_birch.png deleted file mode 100644 index b113e3a..0000000 Binary files a/website/blocks/planks_birch.png and /dev/null differ diff --git a/website/blocks/planks_jungle.png b/website/blocks/planks_jungle.png deleted file mode 100644 index e3fe82d..0000000 Binary files a/website/blocks/planks_jungle.png and /dev/null differ diff --git a/website/blocks/planks_oak.png b/website/blocks/planks_oak.png deleted file mode 100644 index 346f77d..0000000 Binary files a/website/blocks/planks_oak.png and /dev/null differ diff --git a/website/blocks/planks_spruce.png b/website/blocks/planks_spruce.png deleted file mode 100644 index f45fa94..0000000 Binary files a/website/blocks/planks_spruce.png and /dev/null differ diff --git a/website/blocks/portal.png b/website/blocks/portal.png deleted file mode 100644 index 96859e2..0000000 Binary files a/website/blocks/portal.png and /dev/null differ diff --git a/website/blocks/portal.png.mcmeta b/website/blocks/portal.png.mcmeta deleted file mode 100644 index 4f0718a..0000000 --- a/website/blocks/portal.png.mcmeta +++ /dev/null @@ -1,3 +0,0 @@ -{ - "animation": {} -} \ No newline at end of file diff --git a/website/blocks/potatoes_stage_0.png b/website/blocks/potatoes_stage_0.png deleted file mode 100644 index c1ef732..0000000 Binary files a/website/blocks/potatoes_stage_0.png and /dev/null differ diff --git a/website/blocks/potatoes_stage_1.png b/website/blocks/potatoes_stage_1.png deleted file mode 100644 index 1275f4f..0000000 Binary files a/website/blocks/potatoes_stage_1.png and /dev/null differ diff --git a/website/blocks/potatoes_stage_2.png b/website/blocks/potatoes_stage_2.png deleted file mode 100644 index b7347df..0000000 Binary files a/website/blocks/potatoes_stage_2.png and /dev/null differ diff --git a/website/blocks/potatoes_stage_3.png b/website/blocks/potatoes_stage_3.png deleted file mode 100644 index d7e8185..0000000 Binary files a/website/blocks/potatoes_stage_3.png and /dev/null differ diff --git a/website/blocks/prismarine_bricks.png b/website/blocks/prismarine_bricks.png deleted file mode 100644 index 5890690..0000000 Binary files a/website/blocks/prismarine_bricks.png and /dev/null differ diff --git a/website/blocks/prismarine_dark.png b/website/blocks/prismarine_dark.png deleted file mode 100644 index 07bed85..0000000 Binary files a/website/blocks/prismarine_dark.png and /dev/null differ diff --git a/website/blocks/prismarine_rough.png b/website/blocks/prismarine_rough.png deleted file mode 100644 index fe133e2..0000000 Binary files a/website/blocks/prismarine_rough.png and /dev/null differ diff --git a/website/blocks/prismarine_rough.png.mcmeta b/website/blocks/prismarine_rough.png.mcmeta deleted file mode 100644 index 410b327..0000000 --- a/website/blocks/prismarine_rough.png.mcmeta +++ /dev/null @@ -1,30 +0,0 @@ -{ - "animation": { - "frametime": 300, - "interpolate": true, - "frames": [ - 0, - 1, - 0, - 2, - 0, - 3, - 0, - 1, - 2, - 1, - 3, - 1, - 0, - 2, - 1, - 2, - 3, - 2, - 0, - 3, - 1, - 3 - ] - } -} diff --git a/website/blocks/pumpkin_face_off.png b/website/blocks/pumpkin_face_off.png deleted file mode 100644 index ecef025..0000000 Binary files a/website/blocks/pumpkin_face_off.png and /dev/null differ diff --git a/website/blocks/pumpkin_face_on.png b/website/blocks/pumpkin_face_on.png deleted file mode 100644 index 907f499..0000000 Binary files a/website/blocks/pumpkin_face_on.png and /dev/null differ diff --git a/website/blocks/pumpkin_side.png b/website/blocks/pumpkin_side.png deleted file mode 100644 index 75dfc47..0000000 Binary files a/website/blocks/pumpkin_side.png and /dev/null differ diff --git a/website/blocks/pumpkin_stem_connected.png b/website/blocks/pumpkin_stem_connected.png deleted file mode 100644 index 6a5c10e..0000000 Binary files a/website/blocks/pumpkin_stem_connected.png and /dev/null differ diff --git a/website/blocks/pumpkin_stem_disconnected.png b/website/blocks/pumpkin_stem_disconnected.png deleted file mode 100644 index 38065ef..0000000 Binary files a/website/blocks/pumpkin_stem_disconnected.png and /dev/null differ diff --git a/website/blocks/pumpkin_top.png b/website/blocks/pumpkin_top.png deleted file mode 100644 index 297ce3c..0000000 Binary files a/website/blocks/pumpkin_top.png and /dev/null differ diff --git a/website/blocks/quartz_block_bottom.png b/website/blocks/quartz_block_bottom.png deleted file mode 100644 index 7e16c7c..0000000 Binary files a/website/blocks/quartz_block_bottom.png and /dev/null differ diff --git a/website/blocks/quartz_block_chiseled.png b/website/blocks/quartz_block_chiseled.png deleted file mode 100644 index 80465a1..0000000 Binary files a/website/blocks/quartz_block_chiseled.png and /dev/null differ diff --git a/website/blocks/quartz_block_chiseled_top.png b/website/blocks/quartz_block_chiseled_top.png deleted file mode 100644 index 44073e5..0000000 Binary files a/website/blocks/quartz_block_chiseled_top.png and /dev/null differ diff --git a/website/blocks/quartz_block_lines.png b/website/blocks/quartz_block_lines.png deleted file mode 100644 index 184ecd2..0000000 Binary files a/website/blocks/quartz_block_lines.png and /dev/null differ diff --git a/website/blocks/quartz_block_lines_top.png b/website/blocks/quartz_block_lines_top.png deleted file mode 100644 index 6d20379..0000000 Binary files a/website/blocks/quartz_block_lines_top.png and /dev/null differ diff --git a/website/blocks/quartz_block_side.png b/website/blocks/quartz_block_side.png deleted file mode 100644 index a2cd2ca..0000000 Binary files a/website/blocks/quartz_block_side.png and /dev/null differ diff --git a/website/blocks/quartz_block_top.png b/website/blocks/quartz_block_top.png deleted file mode 100644 index a2cd2ca..0000000 Binary files a/website/blocks/quartz_block_top.png and /dev/null differ diff --git a/website/blocks/quartz_ore.png b/website/blocks/quartz_ore.png deleted file mode 100644 index 4d758c1..0000000 Binary files a/website/blocks/quartz_ore.png and /dev/null differ diff --git a/website/blocks/rail_activator.png b/website/blocks/rail_activator.png deleted file mode 100644 index ce115ba..0000000 Binary files a/website/blocks/rail_activator.png and /dev/null differ diff --git a/website/blocks/rail_activator_powered.png b/website/blocks/rail_activator_powered.png deleted file mode 100644 index a3aaca9..0000000 Binary files a/website/blocks/rail_activator_powered.png and /dev/null differ diff --git a/website/blocks/rail_detector.png b/website/blocks/rail_detector.png deleted file mode 100644 index 92c1466..0000000 Binary files a/website/blocks/rail_detector.png and /dev/null differ diff --git a/website/blocks/rail_detector_powered.png b/website/blocks/rail_detector_powered.png deleted file mode 100644 index a1c6e6b..0000000 Binary files a/website/blocks/rail_detector_powered.png and /dev/null differ diff --git a/website/blocks/rail_golden.png b/website/blocks/rail_golden.png deleted file mode 100644 index 1fc52c3..0000000 Binary files a/website/blocks/rail_golden.png and /dev/null differ diff --git a/website/blocks/rail_golden_powered.png b/website/blocks/rail_golden_powered.png deleted file mode 100644 index bd343be..0000000 Binary files a/website/blocks/rail_golden_powered.png and /dev/null differ diff --git a/website/blocks/rail_normal.png b/website/blocks/rail_normal.png deleted file mode 100644 index d609236..0000000 Binary files a/website/blocks/rail_normal.png and /dev/null differ diff --git a/website/blocks/rail_normal_turned.png b/website/blocks/rail_normal_turned.png deleted file mode 100644 index f394a23..0000000 Binary files a/website/blocks/rail_normal_turned.png and /dev/null differ diff --git a/website/blocks/red_sand.png b/website/blocks/red_sand.png deleted file mode 100644 index b216a42..0000000 Binary files a/website/blocks/red_sand.png and /dev/null differ diff --git a/website/blocks/red_sandstone_bottom.png b/website/blocks/red_sandstone_bottom.png deleted file mode 100644 index 7e7a8c9..0000000 Binary files a/website/blocks/red_sandstone_bottom.png and /dev/null differ diff --git a/website/blocks/red_sandstone_carved.png b/website/blocks/red_sandstone_carved.png deleted file mode 100644 index 41070da..0000000 Binary files a/website/blocks/red_sandstone_carved.png and /dev/null differ diff --git a/website/blocks/red_sandstone_normal.png b/website/blocks/red_sandstone_normal.png deleted file mode 100644 index f641784..0000000 Binary files a/website/blocks/red_sandstone_normal.png and /dev/null differ diff --git a/website/blocks/red_sandstone_smooth.png b/website/blocks/red_sandstone_smooth.png deleted file mode 100644 index fd44a72..0000000 Binary files a/website/blocks/red_sandstone_smooth.png and /dev/null differ diff --git a/website/blocks/red_sandstone_top.png b/website/blocks/red_sandstone_top.png deleted file mode 100644 index 794dbeb..0000000 Binary files a/website/blocks/red_sandstone_top.png and /dev/null differ diff --git a/website/blocks/redstone_block.png b/website/blocks/redstone_block.png deleted file mode 100644 index fcf6b40..0000000 Binary files a/website/blocks/redstone_block.png and /dev/null differ diff --git a/website/blocks/redstone_dust_cross.png b/website/blocks/redstone_dust_cross.png deleted file mode 100644 index dcec893..0000000 Binary files a/website/blocks/redstone_dust_cross.png and /dev/null differ diff --git a/website/blocks/redstone_dust_cross_overlay.png b/website/blocks/redstone_dust_cross_overlay.png deleted file mode 100644 index 96729e1..0000000 Binary files a/website/blocks/redstone_dust_cross_overlay.png and /dev/null differ diff --git a/website/blocks/redstone_dust_line.png b/website/blocks/redstone_dust_line.png deleted file mode 100644 index ff0fb23..0000000 Binary files a/website/blocks/redstone_dust_line.png and /dev/null differ diff --git a/website/blocks/redstone_dust_line_overlay.png b/website/blocks/redstone_dust_line_overlay.png deleted file mode 100644 index 9f24cbc..0000000 Binary files a/website/blocks/redstone_dust_line_overlay.png and /dev/null differ diff --git a/website/blocks/redstone_lamp_off.png b/website/blocks/redstone_lamp_off.png deleted file mode 100644 index 522765b..0000000 Binary files a/website/blocks/redstone_lamp_off.png and /dev/null differ diff --git a/website/blocks/redstone_lamp_on.png b/website/blocks/redstone_lamp_on.png deleted file mode 100644 index 9562ef3..0000000 Binary files a/website/blocks/redstone_lamp_on.png and /dev/null differ diff --git a/website/blocks/redstone_ore.png b/website/blocks/redstone_ore.png deleted file mode 100644 index 575a488..0000000 Binary files a/website/blocks/redstone_ore.png and /dev/null differ diff --git a/website/blocks/redstone_torch_off.png b/website/blocks/redstone_torch_off.png deleted file mode 100644 index 635eabd..0000000 Binary files a/website/blocks/redstone_torch_off.png and /dev/null differ diff --git a/website/blocks/redstone_torch_on.png b/website/blocks/redstone_torch_on.png deleted file mode 100644 index 2983d6c..0000000 Binary files a/website/blocks/redstone_torch_on.png and /dev/null differ diff --git a/website/blocks/reeds.png b/website/blocks/reeds.png deleted file mode 100644 index 64bbfe0..0000000 Binary files a/website/blocks/reeds.png and /dev/null differ diff --git a/website/blocks/repeater_off.png b/website/blocks/repeater_off.png deleted file mode 100644 index 8634669..0000000 Binary files a/website/blocks/repeater_off.png and /dev/null differ diff --git a/website/blocks/repeater_on.png b/website/blocks/repeater_on.png deleted file mode 100644 index d71d0d9..0000000 Binary files a/website/blocks/repeater_on.png and /dev/null differ diff --git a/website/blocks/sand.png b/website/blocks/sand.png deleted file mode 100644 index 86b9654..0000000 Binary files a/website/blocks/sand.png and /dev/null differ diff --git a/website/blocks/sandstone_bottom.png b/website/blocks/sandstone_bottom.png deleted file mode 100644 index e102220..0000000 Binary files a/website/blocks/sandstone_bottom.png and /dev/null differ diff --git a/website/blocks/sandstone_carved.png b/website/blocks/sandstone_carved.png deleted file mode 100644 index 9bd7fa1..0000000 Binary files a/website/blocks/sandstone_carved.png and /dev/null differ diff --git a/website/blocks/sandstone_normal.png b/website/blocks/sandstone_normal.png deleted file mode 100644 index 1b79145..0000000 Binary files a/website/blocks/sandstone_normal.png and /dev/null differ diff --git a/website/blocks/sandstone_smooth.png b/website/blocks/sandstone_smooth.png deleted file mode 100644 index ef118bd..0000000 Binary files a/website/blocks/sandstone_smooth.png and /dev/null differ diff --git a/website/blocks/sandstone_top.png b/website/blocks/sandstone_top.png deleted file mode 100644 index bb5b157..0000000 Binary files a/website/blocks/sandstone_top.png and /dev/null differ diff --git a/website/blocks/sapling_acacia.png b/website/blocks/sapling_acacia.png deleted file mode 100644 index a1215cb..0000000 Binary files a/website/blocks/sapling_acacia.png and /dev/null differ diff --git a/website/blocks/sapling_birch.png b/website/blocks/sapling_birch.png deleted file mode 100644 index b0dacc5..0000000 Binary files a/website/blocks/sapling_birch.png and /dev/null differ diff --git a/website/blocks/sapling_jungle.png b/website/blocks/sapling_jungle.png deleted file mode 100644 index 4e10b35..0000000 Binary files a/website/blocks/sapling_jungle.png and /dev/null differ diff --git a/website/blocks/sapling_oak.png b/website/blocks/sapling_oak.png deleted file mode 100644 index 1bf1bfa..0000000 Binary files a/website/blocks/sapling_oak.png and /dev/null differ diff --git a/website/blocks/sapling_roofed_oak.png b/website/blocks/sapling_roofed_oak.png deleted file mode 100644 index dcf5588..0000000 Binary files a/website/blocks/sapling_roofed_oak.png and /dev/null differ diff --git a/website/blocks/sapling_spruce.png b/website/blocks/sapling_spruce.png deleted file mode 100644 index 5767d48..0000000 Binary files a/website/blocks/sapling_spruce.png and /dev/null differ diff --git a/website/blocks/sea_lantern.png b/website/blocks/sea_lantern.png deleted file mode 100644 index 4f08fd2..0000000 Binary files a/website/blocks/sea_lantern.png and /dev/null differ diff --git a/website/blocks/sea_lantern.png.mcmeta b/website/blocks/sea_lantern.png.mcmeta deleted file mode 100644 index e8ac9bc..0000000 --- a/website/blocks/sea_lantern.png.mcmeta +++ /dev/null @@ -1,5 +0,0 @@ -{ - "animation": { - "frametime": 5 - } -} diff --git a/website/blocks/slime.png b/website/blocks/slime.png deleted file mode 100644 index 6dbe5cb..0000000 Binary files a/website/blocks/slime.png and /dev/null differ diff --git a/website/blocks/snow.png b/website/blocks/snow.png deleted file mode 100644 index 5c146cd..0000000 Binary files a/website/blocks/snow.png and /dev/null differ diff --git a/website/blocks/soul_sand.png b/website/blocks/soul_sand.png deleted file mode 100644 index fca7e8f..0000000 Binary files a/website/blocks/soul_sand.png and /dev/null differ diff --git a/website/blocks/sponge.png b/website/blocks/sponge.png deleted file mode 100644 index 7850703..0000000 Binary files a/website/blocks/sponge.png and /dev/null differ diff --git a/website/blocks/sponge_wet.png b/website/blocks/sponge_wet.png deleted file mode 100644 index d024995..0000000 Binary files a/website/blocks/sponge_wet.png and /dev/null differ diff --git a/website/blocks/stone.png b/website/blocks/stone.png deleted file mode 100644 index 87e19ff..0000000 Binary files a/website/blocks/stone.png and /dev/null differ diff --git a/website/blocks/stone_andesite.png b/website/blocks/stone_andesite.png deleted file mode 100644 index 680a899..0000000 Binary files a/website/blocks/stone_andesite.png and /dev/null differ diff --git a/website/blocks/stone_andesite_smooth.png b/website/blocks/stone_andesite_smooth.png deleted file mode 100644 index a77809c..0000000 Binary files a/website/blocks/stone_andesite_smooth.png and /dev/null differ diff --git a/website/blocks/stone_diorite.png b/website/blocks/stone_diorite.png deleted file mode 100644 index 711c838..0000000 Binary files a/website/blocks/stone_diorite.png and /dev/null differ diff --git a/website/blocks/stone_diorite_smooth.png b/website/blocks/stone_diorite_smooth.png deleted file mode 100644 index 8b4f8e3..0000000 Binary files a/website/blocks/stone_diorite_smooth.png and /dev/null differ diff --git a/website/blocks/stone_granite.png b/website/blocks/stone_granite.png deleted file mode 100644 index 07c7af8..0000000 Binary files a/website/blocks/stone_granite.png and /dev/null differ diff --git a/website/blocks/stone_granite_smooth.png b/website/blocks/stone_granite_smooth.png deleted file mode 100644 index d5b37f1..0000000 Binary files a/website/blocks/stone_granite_smooth.png and /dev/null differ diff --git a/website/blocks/stone_slab_side.png b/website/blocks/stone_slab_side.png deleted file mode 100644 index fe2a204..0000000 Binary files a/website/blocks/stone_slab_side.png and /dev/null differ diff --git a/website/blocks/stone_slab_top.png b/website/blocks/stone_slab_top.png deleted file mode 100644 index 090657d..0000000 Binary files a/website/blocks/stone_slab_top.png and /dev/null differ diff --git a/website/blocks/stonebrick.png b/website/blocks/stonebrick.png deleted file mode 100644 index 69138cf..0000000 Binary files a/website/blocks/stonebrick.png and /dev/null differ diff --git a/website/blocks/stonebrick_carved.png b/website/blocks/stonebrick_carved.png deleted file mode 100644 index b7e88db..0000000 Binary files a/website/blocks/stonebrick_carved.png and /dev/null differ diff --git a/website/blocks/stonebrick_cracked.png b/website/blocks/stonebrick_cracked.png deleted file mode 100644 index 918a884..0000000 Binary files a/website/blocks/stonebrick_cracked.png and /dev/null differ diff --git a/website/blocks/stonebrick_mossy.png b/website/blocks/stonebrick_mossy.png deleted file mode 100644 index 5b9fe37..0000000 Binary files a/website/blocks/stonebrick_mossy.png and /dev/null differ diff --git a/website/blocks/tallgrass.png b/website/blocks/tallgrass.png deleted file mode 100644 index 2869848..0000000 Binary files a/website/blocks/tallgrass.png and /dev/null differ diff --git a/website/blocks/tnt_bottom.png b/website/blocks/tnt_bottom.png deleted file mode 100644 index cc2e586..0000000 Binary files a/website/blocks/tnt_bottom.png and /dev/null differ diff --git a/website/blocks/tnt_side.png b/website/blocks/tnt_side.png deleted file mode 100644 index 21109fb..0000000 Binary files a/website/blocks/tnt_side.png and /dev/null differ diff --git a/website/blocks/tnt_top.png b/website/blocks/tnt_top.png deleted file mode 100644 index ceb44b6..0000000 Binary files a/website/blocks/tnt_top.png and /dev/null differ diff --git a/website/blocks/torch_on.png b/website/blocks/torch_on.png deleted file mode 100644 index a2ce41b..0000000 Binary files a/website/blocks/torch_on.png and /dev/null differ diff --git a/website/blocks/trapdoor.png b/website/blocks/trapdoor.png deleted file mode 100644 index 4eadefc..0000000 Binary files a/website/blocks/trapdoor.png and /dev/null differ diff --git a/website/blocks/trip_wire.png b/website/blocks/trip_wire.png deleted file mode 100644 index 42126b8..0000000 Binary files a/website/blocks/trip_wire.png and /dev/null differ diff --git a/website/blocks/trip_wire_source.png b/website/blocks/trip_wire_source.png deleted file mode 100644 index fbd464d..0000000 Binary files a/website/blocks/trip_wire_source.png and /dev/null differ diff --git a/website/blocks/vine.png b/website/blocks/vine.png deleted file mode 100644 index df5e435..0000000 Binary files a/website/blocks/vine.png and /dev/null differ diff --git a/website/blocks/water_flow.png b/website/blocks/water_flow.png deleted file mode 100644 index e72280c..0000000 Binary files a/website/blocks/water_flow.png and /dev/null differ diff --git a/website/blocks/water_flow.png.mcmeta b/website/blocks/water_flow.png.mcmeta deleted file mode 100644 index 4f0718a..0000000 --- a/website/blocks/water_flow.png.mcmeta +++ /dev/null @@ -1,3 +0,0 @@ -{ - "animation": {} -} \ No newline at end of file diff --git a/website/blocks/water_still.png b/website/blocks/water_still.png deleted file mode 100644 index c7e90b0..0000000 Binary files a/website/blocks/water_still.png and /dev/null differ diff --git a/website/blocks/water_still.png.mcmeta b/website/blocks/water_still.png.mcmeta deleted file mode 100644 index 0645f48..0000000 --- a/website/blocks/water_still.png.mcmeta +++ /dev/null @@ -1,5 +0,0 @@ -{ - "animation": { - "frametime": 2 - } -} diff --git a/website/blocks/waterlily.png b/website/blocks/waterlily.png deleted file mode 100644 index f6c84f8..0000000 Binary files a/website/blocks/waterlily.png and /dev/null differ diff --git a/website/blocks/web.png b/website/blocks/web.png deleted file mode 100644 index 7c097f1..0000000 Binary files a/website/blocks/web.png and /dev/null differ diff --git a/website/blocks/wheat_stage_0.png b/website/blocks/wheat_stage_0.png deleted file mode 100644 index 185af6f..0000000 Binary files a/website/blocks/wheat_stage_0.png and /dev/null differ diff --git a/website/blocks/wheat_stage_1.png b/website/blocks/wheat_stage_1.png deleted file mode 100644 index 67588c1..0000000 Binary files a/website/blocks/wheat_stage_1.png and /dev/null differ diff --git a/website/blocks/wheat_stage_2.png b/website/blocks/wheat_stage_2.png deleted file mode 100644 index 3d33792..0000000 Binary files a/website/blocks/wheat_stage_2.png and /dev/null differ diff --git a/website/blocks/wheat_stage_3.png b/website/blocks/wheat_stage_3.png deleted file mode 100644 index 4649f78..0000000 Binary files a/website/blocks/wheat_stage_3.png and /dev/null differ diff --git a/website/blocks/wheat_stage_4.png b/website/blocks/wheat_stage_4.png deleted file mode 100644 index ac04b52..0000000 Binary files a/website/blocks/wheat_stage_4.png and /dev/null differ diff --git a/website/blocks/wheat_stage_5.png b/website/blocks/wheat_stage_5.png deleted file mode 100644 index 1ea81ac..0000000 Binary files a/website/blocks/wheat_stage_5.png and /dev/null differ diff --git a/website/blocks/wheat_stage_6.png b/website/blocks/wheat_stage_6.png deleted file mode 100644 index cb5f195..0000000 Binary files a/website/blocks/wheat_stage_6.png and /dev/null differ diff --git a/website/blocks/wheat_stage_7.png b/website/blocks/wheat_stage_7.png deleted file mode 100644 index 7acafb3..0000000 Binary files a/website/blocks/wheat_stage_7.png and /dev/null differ diff --git a/website/blocks/wool_colored_black.png b/website/blocks/wool_colored_black.png deleted file mode 100644 index b74d5c9..0000000 Binary files a/website/blocks/wool_colored_black.png and /dev/null differ diff --git a/website/blocks/wool_colored_blue.png b/website/blocks/wool_colored_blue.png deleted file mode 100644 index ce9515f..0000000 Binary files a/website/blocks/wool_colored_blue.png and /dev/null differ diff --git a/website/blocks/wool_colored_brown.png b/website/blocks/wool_colored_brown.png deleted file mode 100644 index b4dc3c5..0000000 Binary files a/website/blocks/wool_colored_brown.png and /dev/null differ diff --git a/website/blocks/wool_colored_cyan.png b/website/blocks/wool_colored_cyan.png deleted file mode 100644 index ca0800a..0000000 Binary files a/website/blocks/wool_colored_cyan.png and /dev/null differ diff --git a/website/blocks/wool_colored_gray.png b/website/blocks/wool_colored_gray.png deleted file mode 100644 index 6409ff2..0000000 Binary files a/website/blocks/wool_colored_gray.png and /dev/null differ diff --git a/website/blocks/wool_colored_green.png b/website/blocks/wool_colored_green.png deleted file mode 100644 index a7be6d7..0000000 Binary files a/website/blocks/wool_colored_green.png and /dev/null differ diff --git a/website/blocks/wool_colored_light_blue.png b/website/blocks/wool_colored_light_blue.png deleted file mode 100644 index 72d9d9e..0000000 Binary files a/website/blocks/wool_colored_light_blue.png and /dev/null differ diff --git a/website/blocks/wool_colored_lime.png b/website/blocks/wool_colored_lime.png deleted file mode 100644 index bf56389..0000000 Binary files a/website/blocks/wool_colored_lime.png and /dev/null differ diff --git a/website/blocks/wool_colored_magenta.png b/website/blocks/wool_colored_magenta.png deleted file mode 100644 index 3af6747..0000000 Binary files a/website/blocks/wool_colored_magenta.png and /dev/null differ diff --git a/website/blocks/wool_colored_orange.png b/website/blocks/wool_colored_orange.png deleted file mode 100644 index eefe6de..0000000 Binary files a/website/blocks/wool_colored_orange.png and /dev/null differ diff --git a/website/blocks/wool_colored_pink.png b/website/blocks/wool_colored_pink.png deleted file mode 100644 index c2785af..0000000 Binary files a/website/blocks/wool_colored_pink.png and /dev/null differ diff --git a/website/blocks/wool_colored_purple.png b/website/blocks/wool_colored_purple.png deleted file mode 100644 index 76f68d6..0000000 Binary files a/website/blocks/wool_colored_purple.png and /dev/null differ diff --git a/website/blocks/wool_colored_red.png b/website/blocks/wool_colored_red.png deleted file mode 100644 index 0cff7a9..0000000 Binary files a/website/blocks/wool_colored_red.png and /dev/null differ diff --git a/website/blocks/wool_colored_silver.png b/website/blocks/wool_colored_silver.png deleted file mode 100644 index 756d9b0..0000000 Binary files a/website/blocks/wool_colored_silver.png and /dev/null differ diff --git a/website/blocks/wool_colored_white.png b/website/blocks/wool_colored_white.png deleted file mode 100644 index abc7999..0000000 Binary files a/website/blocks/wool_colored_white.png and /dev/null differ diff --git a/website/blocks/wool_colored_yellow.png b/website/blocks/wool_colored_yellow.png deleted file mode 100644 index 4babaaa..0000000 Binary files a/website/blocks/wool_colored_yellow.png and /dev/null differ diff --git a/website/boot.js b/website/boot.js deleted file mode 100644 index 3d0143e..0000000 --- a/website/boot.js +++ /dev/null @@ -1,164 +0,0 @@ -/** - * BIOS Boot Animation - * Displays a retro BIOS-style boot sequence before the terminal loads - */ - -const BIOS_LINES = [ - { text: 'LitRuv BIOS v4.20.69', class: 'white', delay: 300 }, - { text: 'Copyright (C) 2024-2026 LitRuv Industries', class: '', delay: 200 }, - { text: '', delay: 400 }, - { text: 'CPU: Quantum Core i9-42069 @ 6.9GHz', class: '', delay: 300 }, - { text: 'Memory Test: ', class: '', inline: true, delay: 200 }, - { text: '65536K OK', class: 'highlight', delay: 600 }, - { text: '', delay: 300 }, - { text: 'Detecting Primary Master... LitDrive SSD 2TB', class: '', delay: 400 }, - { text: 'Detecting Primary Slave... None', class: '', delay: 300 }, - { text: '', delay: 400 }, - { text: 'Initializing Terminal Interface...', class: 'highlight', delay: 500 }, - { text: 'Loading modules: [', class: '', inline: true, delay: 200 }, - { text: '████████████████████', class: 'highlight', inline: true, delay: 800 }, - { text: '] 100%', class: '', delay: 300 }, - { text: '', delay: 200 }, - { text: 'Starting LIT.RUV.WTF Terminal...', class: 'highlight', delay: 400 } -]; - -const BOOT_STARTUP_SOUND_PATH = window.location.hostname.endsWith('neocities.org') - ? 'https://lit.ruv.wtf/sounds/551405__nakkivene66__old-pc-startup-idle-shutdown.wav' - : 'sounds/551405__nakkivene66__old-pc-startup-idle-shutdown.wav'; - -let bootStartupSoundPlayed = false; -let bootStartupSoundUnlockBound = false; - -/** - * Play the boot startup sound as early as possible. - * Falls back to first user interaction when autoplay is blocked. - * @returns {Promise} - */ -async function playBootStartupSound() { - if (bootStartupSoundPlayed) { - return; - } - - try { - const startupSound = new Audio(BOOT_STARTUP_SOUND_PATH); - startupSound.preload = 'auto'; - startupSound.volume = 0.55; - await startupSound.play(); - bootStartupSoundPlayed = true; - bootStartupSoundUnlockBound = false; - return; - } catch (error) { - if (bootStartupSoundUnlockBound) { - return; - } - - bootStartupSoundUnlockBound = true; - const unlockAndPlay = async () => { - if (bootStartupSoundPlayed) { - return; - } - - try { - const deferredSound = new Audio(BOOT_STARTUP_SOUND_PATH); - deferredSound.preload = 'auto'; - deferredSound.volume = 0.55; - await deferredSound.play(); - bootStartupSoundPlayed = true; - } catch (retryError) { - // Keep boot sequence running even if sound cannot play. - } - }; - - document.addEventListener('pointerdown', unlockAndPlay, { once: true }); - document.addEventListener('keydown', unlockAndPlay, { once: true }); - } -} - -/** - * Run the BIOS boot animation - * @returns {Promise} Resolves when animation is complete - */ -function runBootAnimation() { - return new Promise((resolve) => { - void playBootStartupSound(); - const bootScreen = document.getElementById('bootScreen'); - const biosText = document.getElementById('biosText'); - const pageFade = document.getElementById('pageFade'); - - if (!bootScreen || !biosText) { - if (pageFade) pageFade.classList.add('fade-out'); - resolve(); - return; - } - - let skipped = false; - const timeouts = []; - - // Skip animation on space key press - const skipHandler = (event) => { - if (event.code === 'Space' && !skipped) { - event.preventDefault(); - skipped = true; - - // Clear all pending timeouts - timeouts.forEach(timeout => clearTimeout(timeout)); - - // Complete the boot screen immediately - bootScreen.classList.add('fade-out'); - setTimeout(() => { - bootScreen.style.display = 'none'; - if (pageFade) pageFade.style.display = 'none'; - document.removeEventListener('keydown', skipHandler); - resolve(); - }, 100); - } - }; - - document.addEventListener('keydown', skipHandler); - - // Start fade in from black after brief delay - timeouts.push(setTimeout(() => { - if (pageFade && !skipped) pageFade.classList.add('fade-out'); - }, 200)); - - let lineIndex = 0; - let totalDelay = 800; // Start after fade begins - const baseDelay = 150; - - BIOS_LINES.forEach((line, index) => { - const delay = totalDelay; - totalDelay += line.delay || baseDelay; - - timeouts.push(setTimeout(() => { - if (skipped) return; - - const span = document.createElement('span'); - if (line.class) { - span.className = line.class; - } - span.textContent = line.text; - biosText.appendChild(span); - - if (!line.inline) { - biosText.appendChild(document.createTextNode('\n')); - } - }, delay)); - }); - - // Fade out and remove boot screen - timeouts.push(setTimeout(() => { - if (skipped) return; - - bootScreen.classList.add('fade-out'); - timeouts.push(setTimeout(() => { - bootScreen.style.display = 'none'; - if (pageFade) pageFade.style.display = 'none'; - document.removeEventListener('keydown', skipHandler); - resolve(); - }, 500)); - }, totalDelay + 300)); - }); -} - -// Export for use in terminal.js -window.runBootAnimation = runBootAnimation; diff --git a/website/bulge-map.png b/website/bulge-map.png deleted file mode 100644 index 0c298c2..0000000 Binary files a/website/bulge-map.png and /dev/null differ diff --git a/website/crt-bulge.js b/website/crt-bulge.js deleted file mode 100644 index 619bb33..0000000 --- a/website/crt-bulge.js +++ /dev/null @@ -1,238 +0,0 @@ -/** - * CRT Bulge Coordinate Transformer - * Intercepts mouse events and applies inverse distortion to match the visual bulge effect - */ -class CRTBulgeTransformer { - constructor(containerSelector, strength = 0.15) { - this.container = document.querySelector(containerSelector); - this.strength = strength; - this.setupEventListeners(); - } - - /** - * Apply inverse barrel distortion to transform visual coords to DOM coords - * @param {number} x - Visual X coordinate relative to container - * @param {number} y - Visual Y coordinate relative to container - * @param {number} width - Container width - * @param {number} height - Container height - * @returns {{x: number, y: number}} - Transformed coordinates - */ - inverseDistort(x, y, width, height) { - // Normalize to -1 to 1 (center is 0,0) - const nx = (x / width) * 2 - 1; - const ny = (y / height) * 2 - 1; - - // The visual filter pushes pixels outward based on distance squared - // To invert: we need to find where this pixel came FROM - // Using Newton-Raphson iteration to solve the inverse - - let ux = nx; - let uy = ny; - - // Iterate to find the original position - for (let i = 0; i < 5; i++) { - const distSq = ux * ux + uy * uy; - const factor = 1 + this.strength * distSq; - - // Forward distortion: visual = original * factor - // Inverse: original = visual / factor (approximately) - ux = nx / factor; - uy = ny / factor; - } - - // Convert back to pixel coordinates - const newX = ((ux + 1) / 2) * width; - const newY = ((uy + 1) / 2) * height; - - return { x: newX, y: newY }; - } - - /** - * Transform mouse event coordinates - * @param {MouseEvent} e - Original mouse event - * @returns {{x: number, y: number}} - Transformed page coordinates - */ - transformEvent(e) { - const rect = this.container.getBoundingClientRect(); - - // Get position relative to container - const relX = e.clientX - rect.left; - const relY = e.clientY - rect.top; - - // Apply inverse distortion - const transformed = this.inverseDistort(relX, relY, rect.width, rect.height); - - // Convert back to page coordinates - return { - x: rect.left + transformed.x, - y: rect.top + transformed.y - }; - } - - /** - * Set up event listeners for mouse interaction - */ - setupEventListeners() { - // Create invisible overlay to capture events - const overlay = document.createElement('div'); - overlay.id = 'crt-event-overlay'; - overlay.style.cssText = ` - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 10000; - cursor: inherit; - `; - document.body.appendChild(overlay); - - // Track currently hovered element for hover states - let lastHoveredElement = null; - - // Handle mouse movement - overlay.addEventListener('mousemove', (e) => { - const transformed = this.transformEvent(e); - - // Temporarily hide overlay to find element underneath - overlay.style.pointerEvents = 'none'; - const elementBelow = document.elementFromPoint(transformed.x, transformed.y); - overlay.style.pointerEvents = 'auto'; - - // Update cursor based on element below - if (elementBelow) { - const computedStyle = window.getComputedStyle(elementBelow); - overlay.style.cursor = computedStyle.cursor; - - // Handle hover state changes - if (elementBelow !== lastHoveredElement) { - if (lastHoveredElement) { - lastHoveredElement.dispatchEvent(new MouseEvent('mouseleave', { - bubbles: true, - clientX: transformed.x, - clientY: transformed.y - })); - } - elementBelow.dispatchEvent(new MouseEvent('mouseenter', { - bubbles: true, - clientX: transformed.x, - clientY: transformed.y - })); - lastHoveredElement = elementBelow; - } - - // Dispatch mousemove to element - elementBelow.dispatchEvent(new MouseEvent('mousemove', { - bubbles: true, - clientX: transformed.x, - clientY: transformed.y - })); - } - }); - - // Handle clicks - overlay.addEventListener('click', (e) => { - e.preventDefault(); - const transformed = this.transformEvent(e); - - overlay.style.pointerEvents = 'none'; - const elementBelow = document.elementFromPoint(transformed.x, transformed.y); - overlay.style.pointerEvents = 'auto'; - - if (elementBelow) { - elementBelow.dispatchEvent(new MouseEvent('click', { - bubbles: true, - clientX: transformed.x, - clientY: transformed.y - })); - - // Focus if it's an interactive element - if (elementBelow.tagName === 'INPUT' || elementBelow.tagName === 'TEXTAREA' || elementBelow.tabIndex >= 0) { - elementBelow.focus(); - } - } - }); - - // Handle mousedown - overlay.addEventListener('mousedown', (e) => { - const transformed = this.transformEvent(e); - - overlay.style.pointerEvents = 'none'; - const elementBelow = document.elementFromPoint(transformed.x, transformed.y); - overlay.style.pointerEvents = 'auto'; - - if (elementBelow) { - elementBelow.dispatchEvent(new MouseEvent('mousedown', { - bubbles: true, - clientX: transformed.x, - clientY: transformed.y, - button: e.button - })); - } - }); - - // Handle mouseup - overlay.addEventListener('mouseup', (e) => { - const transformed = this.transformEvent(e); - - overlay.style.pointerEvents = 'none'; - const elementBelow = document.elementFromPoint(transformed.x, transformed.y); - overlay.style.pointerEvents = 'auto'; - - if (elementBelow) { - elementBelow.dispatchEvent(new MouseEvent('mouseup', { - bubbles: true, - clientX: transformed.x, - clientY: transformed.y, - button: e.button - })); - } - }); - - // Handle wheel/scroll - overlay.addEventListener('wheel', (e) => { - const transformed = this.transformEvent(e); - - overlay.style.pointerEvents = 'none'; - const elementBelow = document.elementFromPoint(transformed.x, transformed.y); - overlay.style.pointerEvents = 'auto'; - - if (elementBelow) { - elementBelow.dispatchEvent(new WheelEvent('wheel', { - bubbles: true, - clientX: transformed.x, - clientY: transformed.y, - deltaX: e.deltaX, - deltaY: e.deltaY, - deltaMode: e.deltaMode - })); - } - }, { passive: true }); - - // Handle context menu - overlay.addEventListener('contextmenu', (e) => { - const transformed = this.transformEvent(e); - - overlay.style.pointerEvents = 'none'; - const elementBelow = document.elementFromPoint(transformed.x, transformed.y); - overlay.style.pointerEvents = 'auto'; - - if (elementBelow) { - const event = new MouseEvent('contextmenu', { - bubbles: true, - clientX: transformed.x, - clientY: transformed.y - }); - if (!elementBelow.dispatchEvent(event)) { - e.preventDefault(); - } - } - }); - } -} - -// Initialize when DOM is ready -document.addEventListener('DOMContentLoaded', () => { - // Match the strength value from create-bulge-map.js - new CRTBulgeTransformer('.container', 0.15); -}); diff --git a/website/crt-canvas.js b/website/crt-canvas.js deleted file mode 100644 index 4bf0cb5..0000000 --- a/website/crt-canvas.js +++ /dev/null @@ -1,459 +0,0 @@ -/** - * CRT Canvas Renderer - * Renders content to a canvas with WebGL barrel distortion shader - * Handles input coordinate transformation automatically - */ -class CRTCanvasRenderer { - /** - * @param {HTMLElement} sourceElement - Element to render (will be hidden) - * @param {Object} options - Configuration options - */ - constructor(sourceElement, options = {}) { - this.source = sourceElement; - this.options = { - bulgeStrength: options.bulgeStrength ?? 0.08, - scanlineIntensity: options.scanlineIntensity ?? 0.1, - vignetteStrength: options.vignetteStrength ?? 0.2, - fps: options.fps ?? 60, - ...options - }; - - this.canvas = null; - this.gl = null; - this.program = null; - this.animationId = null; - - this.init(); - } - - /** - * Initialize the canvas and WebGL context - */ - init() { - // Create canvas - this.canvas = document.createElement('canvas'); - this.canvas.id = 'crt-canvas'; - this.canvas.style.cssText = ` - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 1; - `; - - // Insert canvas before source - this.source.parentNode.insertBefore(this.canvas, this.source); - - // Hide source but keep it functional - this.source.style.position = 'absolute'; - this.source.style.opacity = '0'; - this.source.style.pointerEvents = 'none'; - - // Get WebGL context - this.gl = this.canvas.getContext('webgl', { - alpha: true, - antialias: false, - preserveDrawingBuffer: true - }); - - if (!this.gl) { - console.error('WebGL not supported, falling back to source element'); - this.source.style.opacity = '1'; - this.source.style.pointerEvents = 'auto'; - this.canvas.remove(); - return; - } - - this.setupShaders(); - this.setupGeometry(); - this.setupTexture(); - this.setupEventListeners(); - this.resize(); - - window.addEventListener('resize', () => this.resize()); - - this.startRenderLoop(); - } - - /** - * Create and compile WebGL shaders - */ - setupShaders() { - const gl = this.gl; - - // Vertex shader - const vertexSource = ` - attribute vec2 a_position; - attribute vec2 a_texCoord; - varying vec2 v_texCoord; - - void main() { - gl_Position = vec4(a_position, 0.0, 1.0); - v_texCoord = a_texCoord; - } - `; - - // Fragment shader with barrel distortion - const fragmentSource = ` - precision mediump float; - - uniform sampler2D u_texture; - uniform vec2 u_resolution; - uniform float u_bulgeStrength; - uniform float u_scanlineIntensity; - uniform float u_vignetteStrength; - uniform float u_time; - - varying vec2 v_texCoord; - - vec2 barrelDistort(vec2 uv) { - // Center UV around origin - vec2 centered = uv * 2.0 - 1.0; - - // Calculate distance from center - float distSq = dot(centered, centered); - - // Apply barrel distortion - float distortion = 1.0 + u_bulgeStrength * distSq; - centered *= distortion; - - // Convert back to 0-1 range - return centered * 0.5 + 0.5; - } - - void main() { - // Apply barrel distortion - vec2 distortedUV = barrelDistort(v_texCoord); - - // Check if UV is out of bounds - if (distortedUV.x < 0.0 || distortedUV.x > 1.0 || - distortedUV.y < 0.0 || distortedUV.y > 1.0) { - gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); - return; - } - - // Sample texture - vec4 color = texture2D(u_texture, distortedUV); - - // Scanlines - float scanline = sin(distortedUV.y * u_resolution.y * 1.5) * 0.5 + 0.5; - color.rgb *= 1.0 - (u_scanlineIntensity * (1.0 - scanline)); - - // Subtle RGB shift for chromatic aberration - float shift = u_bulgeStrength * 0.002; - float r = texture2D(u_texture, distortedUV + vec2(shift, 0.0)).r; - float b = texture2D(u_texture, distortedUV - vec2(shift, 0.0)).b; - color.r = mix(color.r, r, 0.5); - color.b = mix(color.b, b, 0.5); - - // Vignette - vec2 vignetteUV = v_texCoord * 2.0 - 1.0; - float vignette = 1.0 - dot(vignetteUV, vignetteUV) * u_vignetteStrength; - color.rgb *= vignette; - - gl_FragColor = color; - } - `; - - // Compile shaders - const vertexShader = this.compileShader(gl.VERTEX_SHADER, vertexSource); - const fragmentShader = this.compileShader(gl.FRAGMENT_SHADER, fragmentSource); - - // Create program - this.program = gl.createProgram(); - gl.attachShader(this.program, vertexShader); - gl.attachShader(this.program, fragmentShader); - gl.linkProgram(this.program); - - if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) { - console.error('Shader program failed to link:', gl.getProgramInfoLog(this.program)); - } - - gl.useProgram(this.program); - - // Get uniform locations - this.uniforms = { - texture: gl.getUniformLocation(this.program, 'u_texture'), - resolution: gl.getUniformLocation(this.program, 'u_resolution'), - bulgeStrength: gl.getUniformLocation(this.program, 'u_bulgeStrength'), - scanlineIntensity: gl.getUniformLocation(this.program, 'u_scanlineIntensity'), - vignetteStrength: gl.getUniformLocation(this.program, 'u_vignetteStrength'), - time: gl.getUniformLocation(this.program, 'u_time') - }; - } - - /** - * Compile a shader - */ - compileShader(type, source) { - const gl = this.gl; - const shader = gl.createShader(type); - gl.shaderSource(shader, source); - gl.compileShader(shader); - - if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { - console.error('Shader compile error:', gl.getShaderInfoLog(shader)); - gl.deleteShader(shader); - return null; - } - - return shader; - } - - /** - * Set up geometry (full-screen quad) - */ - setupGeometry() { - const gl = this.gl; - - // Position attribute - const positionBuffer = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); - gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ - -1, -1, 1, -1, -1, 1, - -1, 1, 1, -1, 1, 1 - ]), gl.STATIC_DRAW); - - const positionLoc = gl.getAttribLocation(this.program, 'a_position'); - gl.enableVertexAttribArray(positionLoc); - gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0); - - // Texture coordinate attribute - const texCoordBuffer = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer); - gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ - 0, 1, 1, 1, 0, 0, - 0, 0, 1, 1, 1, 0 - ]), gl.STATIC_DRAW); - - const texCoordLoc = gl.getAttribLocation(this.program, 'a_texCoord'); - gl.enableVertexAttribArray(texCoordLoc); - gl.vertexAttribPointer(texCoordLoc, 2, gl.FLOAT, false, 0, 0); - } - - /** - * Set up texture for rendering source element - */ - setupTexture() { - const gl = this.gl; - - this.texture = gl.createTexture(); - gl.bindTexture(gl.TEXTURE_2D, this.texture); - - // Set texture parameters - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); - } - - /** - * Handle canvas resize - */ - resize() { - const rect = this.source.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - - this.canvas.width = rect.width * dpr; - this.canvas.height = rect.height * dpr; - this.canvas.style.width = rect.width + 'px'; - this.canvas.style.height = rect.height + 'px'; - - this.gl.viewport(0, 0, this.canvas.width, this.canvas.height); - } - - /** - * Apply inverse barrel distortion to get source coordinates from canvas coordinates - */ - inverseDistort(canvasX, canvasY) { - const rect = this.canvas.getBoundingClientRect(); - - // Normalize to 0-1 - let u = canvasX / rect.width; - let v = canvasY / rect.height; - - // Center around 0 - let x = u * 2 - 1; - let y = v * 2 - 1; - - // Iteratively solve inverse (Newton-Raphson) - for (let i = 0; i < 10; i++) { - const distSq = x * x + y * y; - const distortion = 1 + this.options.bulgeStrength * distSq; - - // The forward transform is: distorted = original * distortion - // So inverse is approximately: original = distorted / distortion - x = (u * 2 - 1) / distortion; - y = (v * 2 - 1) / distortion; - } - - // Convert back to pixel coordinates - const sourceX = ((x + 1) / 2) * rect.width; - const sourceY = ((y + 1) / 2) * rect.height; - - return { x: sourceX, y: sourceY }; - } - - /** - * Set up event listeners for mouse/touch input - */ - setupEventListeners() { - const forwardEvent = (e, type) => { - const rect = this.canvas.getBoundingClientRect(); - const canvasX = e.clientX - rect.left; - const canvasY = e.clientY - rect.top; - - const sourceCoords = this.inverseDistort(canvasX, canvasY); - const sourceX = rect.left + sourceCoords.x; - const sourceY = rect.top + sourceCoords.y; - - // Find element at transformed position in source - this.source.style.pointerEvents = 'auto'; - this.source.style.opacity = '0.001'; // Nearly invisible but still there - const element = document.elementFromPoint(sourceX, sourceY); - this.source.style.pointerEvents = 'none'; - this.source.style.opacity = '0'; - - if (element && this.source.contains(element)) { - const newEvent = new MouseEvent(type, { - bubbles: true, - cancelable: true, - clientX: sourceX, - clientY: sourceY, - button: e.button, - buttons: e.buttons - }); - element.dispatchEvent(newEvent); - - // Update cursor - this.canvas.style.cursor = window.getComputedStyle(element).cursor; - } - }; - - // Mouse events - this.canvas.addEventListener('click', (e) => forwardEvent(e, 'click')); - this.canvas.addEventListener('mousedown', (e) => forwardEvent(e, 'mousedown')); - this.canvas.addEventListener('mouseup', (e) => forwardEvent(e, 'mouseup')); - this.canvas.addEventListener('mousemove', (e) => forwardEvent(e, 'mousemove')); - this.canvas.addEventListener('dblclick', (e) => forwardEvent(e, 'dblclick')); - this.canvas.addEventListener('contextmenu', (e) => { - e.preventDefault(); - forwardEvent(e, 'contextmenu'); - }); - - // Wheel events - this.canvas.addEventListener('wheel', (e) => { - const rect = this.canvas.getBoundingClientRect(); - const canvasX = e.clientX - rect.left; - const canvasY = e.clientY - rect.top; - - const sourceCoords = this.inverseDistort(canvasX, canvasY); - const sourceX = rect.left + sourceCoords.x; - const sourceY = rect.top + sourceCoords.y; - - this.source.style.pointerEvents = 'auto'; - const element = document.elementFromPoint(sourceX, sourceY); - this.source.style.pointerEvents = 'none'; - - if (element && this.source.contains(element)) { - element.dispatchEvent(new WheelEvent('wheel', { - bubbles: true, - clientX: sourceX, - clientY: sourceY, - deltaX: e.deltaX, - deltaY: e.deltaY, - deltaMode: e.deltaMode - })); - } - }, { passive: true }); - - // Keyboard events should go to focused elements naturally - // Focus management - this.canvas.addEventListener('mousedown', () => { - // Focus the terminal when clicking canvas - const terminal = this.source.querySelector('.xterm-helper-textarea'); - if (terminal) { - terminal.focus(); - } - }); - } - - /** - * Capture source element to texture using html2canvas - */ - async captureSource() { - const gl = this.gl; - - // Use html2canvas to render the source element - try { - const canvas = await html2canvas(this.source, { - backgroundColor: null, - scale: window.devicePixelRatio || 1, - logging: false, - useCORS: true - }); - - gl.bindTexture(gl.TEXTURE_2D, this.texture); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas); - } catch (err) { - console.error('Failed to capture source:', err); - } - } - - /** - * Render frame - */ - render(time) { - const gl = this.gl; - - gl.clear(gl.COLOR_BUFFER_BIT); - - // Set uniforms - gl.uniform1i(this.uniforms.texture, 0); - gl.uniform2f(this.uniforms.resolution, this.canvas.width, this.canvas.height); - gl.uniform1f(this.uniforms.bulgeStrength, this.options.bulgeStrength); - gl.uniform1f(this.uniforms.scanlineIntensity, this.options.scanlineIntensity); - gl.uniform1f(this.uniforms.vignetteStrength, this.options.vignetteStrength); - gl.uniform1f(this.uniforms.time, time * 0.001); - - // Draw - gl.drawArrays(gl.TRIANGLES, 0, 6); - } - - /** - * Start the render loop - */ - startRenderLoop() { - const frameInterval = 1000 / this.options.fps; - let lastCapture = 0; - - const loop = async (time) => { - // Capture at reduced rate to save performance - if (time - lastCapture > frameInterval) { - await this.captureSource(); - lastCapture = time; - } - - this.render(time); - this.animationId = requestAnimationFrame(loop); - }; - - this.animationId = requestAnimationFrame(loop); - } - - /** - * Stop rendering - */ - destroy() { - if (this.animationId) { - cancelAnimationFrame(this.animationId); - } - this.canvas.remove(); - this.source.style.opacity = '1'; - this.source.style.pointerEvents = 'auto'; - } -} - -// Export for use -window.CRTCanvasRenderer = CRTCanvasRenderer; diff --git a/website/data/graph.json b/website/data/graph.json new file mode 100644 index 0000000..57256d9 --- /dev/null +++ b/website/data/graph.json @@ -0,0 +1,858 @@ +{ + "settings": { + "draggable": true, + "initialViewbox": { + "x": -33, + "y": -73, + "width": 750, + "height": 412 + }, + "introDurationMs": 700 + }, + "images": [ + { + "src": "images/logo-modern.svg", + "width": 256, + "inline": true, + "position": { + "x": 320, + "y": 90 + }, + "cssStyle": "fill: #ffffff" + }, + { + "src": "images/image.svg", + "width": 256, + "position": { + "x": 0, + "y": 0 + } + } + ], + "nodes": [ + { + "id": "node_custom_event", + "type": "custom_event", + "title": "Begin Play", + "position": { + "x": 340, + "y": 200 + }, + "focusOptions": { + "minWorldBox": { + "width": 750, + "height": 412 + }, + "anchorX": 0.7, + "responsiveWorldBox": [ + { + "minViewportWidth": 768, + "minWorldBox": { + "width": 748, + "height": 600 + }, + "anchorX": 0.7, + "anchorY": 0.6 + }, + { + "minViewportWidth": 480, + "minWorldBox": { + "width": 620, + "height": 338 + }, + "anchorX": 0.75, + "anchorY": 0.75 + } + ], + "anchorY": 0.7 + }, + "inputs": [], + "outputs": [ + { + "id": "exec_out", + "name": "", + "direction": "output", + "kind": "exec" + } + ] + }, + { + "id": "zoom_in_animation", + "type": "lottie", + "title": "Zoom In", + "position": { + "x": 340, + "y": 360 + }, + "width": 300, + "lottieSrc": "data/zoom_in.json", + "inputs": [], + "outputs": [] + }, + { + "id": "node_sequence", + "type": "sequence", + "title": "Sequence", + "position": { + "x": 1180, + "y": 120 + }, + "focusOptions": { + "minWorldBox": { + "width": 300, + "height": 300 + } + }, + "inputs": [ + { + "id": "exec_in", + "name": "Back", + "kind": "exec", + "direction": "input" + } + ], + "outputs": [ + { + "id": "then_0", + "name": "About Me", + "direction": "output", + "kind": "exec" + }, + { + "id": "then_1", + "name": "Socials", + "direction": "output", + "kind": "exec" + }, + { + "id": "then_2", + "name": "Chat", + "direction": "output", + "kind": "exec" + } + ] + }, + { + "id": "node_whoami", + "type": "info", + "title": "Who Am I", + "position": { + "x": 1780, + "y": -640 + }, + "width": 340, + "markdownSrc": "data/whoami.md", + "focusOptions": { + "durationMs": 600, + "minWorldBox": { + "width": 1055, + "height": 770 + }, + "anchorX": 0.2, + "anchorY": 0.5, + "responsiveWorldBox": [ + { + "minViewportWidth": 480, + "minWorldBox": { + "width": 364, + "height": 715 + }, + "anchorX": 0.5, + "anchorY": 0.5 + }, + { + "minViewportWidth": 768, + "minWorldBox": { + "width": 1055, + "height": 604 + }, + "anchorX": 0.2, + "anchorY": 0.5 + } + ] + }, + "inputs": [ + { + "id": "exec_in", + "name": "Back", + "direction": "input", + "kind": "exec" + } + ], + "outputs": [] + }, + { + "id": "socials", + "type": "info", + "title": "Find Me Online", + "position": { + "x": 2460, + "y": 140 + }, + "markdownSrc": "data/socials.md", + "focusOptions": { + "minWorldBox": { + "width": 300, + "height": 300 + } + }, + "inputs": [ + { + "id": "exec_in", + "name": "Back", + "direction": "input", + "kind": "exec" + } + ], + "outputs": [] + }, + { + "id": "node_stringtest", + "type": "pure", + "title": "String Node", + "position": { + "x": 2260, + "y": -280 + }, + "inputs": [], + "outputs": [ + { + "id": "val_out", + "name": "Value", + "direction": "output", + "kind": "string", + "defaultValue": "Hello" + } + ] + }, + { + "id": "btn_run", + "type": "button", + "title": "Run", + "position": { + "x": 2180, + "y": -360 + }, + "inputs": [], + "outputs": [ + { + "id": "exec_out", + "name": "", + "direction": "output", + "kind": "exec" + } + ] + }, + { + "id": "node_timer", + "type": "timer", + "title": "Timer", + "position": { + "x": 2180, + "y": -460 + }, + "inputs": [ + { + "id": "interval", + "name": "Interval (s)", + "direction": "input", + "kind": "number", + "defaultValue": "1", + "min": 0.1, + "max": 10 + } + ], + "outputs": [ + { + "id": "exec_out", + "name": "", + "direction": "output", + "kind": "exec" + } + ] + }, + { + "id": "node_print", + "type": "print", + "title": "Print String", + "position": { + "x": 2540, + "y": -460 + }, + "inputs": [ + { + "id": "exec_in", + "name": "", + "direction": "input", + "kind": "exec" + }, + { + "id": "value", + "name": "Value", + "direction": "input", + "kind": "string", + "defaultValue": "Hello!" + } + ], + "outputs": [ + { + "id": "exec_out", + "name": "", + "direction": "output", + "kind": "exec" + } + ] + }, + { + "id": "string_homeserver", + "type": "pure", + "title": "Homeserver", + "position": { + "x": 1520, + "y": 1020 + }, + "inputs": [], + "outputs": [ + { + "id": "val_out", + "name": "Value", + "direction": "output", + "kind": "string", + "defaultValue": "https://chat.ruv.wtf" + } + ] + }, + { + "id": "string_room", + "type": "pure", + "title": "Room", + "position": { + "x": 1520, + "y": 1100 + }, + "inputs": [], + "outputs": [ + { + "id": "val_out", + "name": "Value", + "direction": "output", + "kind": "string", + "defaultValue": "#general:chat.ruv.wtf" + } + ] + }, + { + "id": "chat_node", + "type": "chat", + "title": "Matrix Chat", + "position": { + "x": 1800, + "y": 920 + }, + "width": 400, + "focusOptions": { + "minWorldBox": { + "width": 1700, + "height": 750 + }, + "anchorX": 0.3, + "anchorY": 0.5, + "responsiveWorldBox": [ + { + "minViewportWidth": 768, + "minWorldBox": { + "width": 800, + "height": 600 + }, + "anchorX": 0.65, + "anchorY": 0.58 + }, + { + "minViewportWidth": 480, + "minWorldBox": { + "width": 436, + "height": 630 + }, + "anchorX": 0.5, + "anchorY": 0.54 + } + ] + }, + "inputs": [ + { + "id": "exec_in", + "name": "Back", + "direction": "input", + "kind": "exec" + }, + { + "id": "chat_object", + "name": "Chat Object", + "direction": "input", + "kind": "object" + }, + { + "id": "homeserver", + "name": "Homeserver", + "direction": "input", + "kind": "string", + "defaultValue": "https://chat.ruv.wtf" + }, + { + "id": "room", + "name": "Room", + "direction": "input", + "kind": "string", + "defaultValue": "#general:chat.ruv.wtf" + }, + { + "id": "guestName", + "name": "Guest Name", + "direction": "input", + "kind": "string", + "defaultValue": "" + } + ], + "outputs": [] + }, + { + "id": "random_name_generator", + "type": "random_name", + "title": "Random Name", + "position": { + "x": 1520, + "y": 1180 + }, + "inputs": [], + "outputs": [ + { + "id": "name", + "name": "Name", + "direction": "output", + "kind": "string" + } + ] + }, + { + "id": "btn_connect_chat", + "type": "button", + "title": "Connect Chat", + "position": { + "x": 1960, + "y": 820 + }, + "inputs": [], + "outputs": [ + { + "id": "exec_out", + "name": "", + "direction": "output", + "kind": "exec" + } + ] + }, + { + "id": "connect_chat_node", + "type": "chat_connect", + "title": "Connect Chat", + "position": { + "x": 2220, + "y": 880 + }, + "inputs": [ + { + "id": "exec_in", + "name": "", + "direction": "input", + "kind": "exec" + }, + { + "id": "target", + "name": "Target", + "direction": "input", + "kind": "object" + } + ], + "outputs": [ + { + "id": "exec_out", + "name": "", + "direction": "output", + "kind": "exec" + } + ] + }, + { + "id": "get_matrix_chat", + "type": "get_matrix_chat", + "title": "Get MatrixChat", + "position": { + "x": 1520, + "y": 940 + }, + "inputs": [], + "outputs": [ + { + "id": "value", + "name": "", + "direction": "output", + "kind": "object", + "defaultValue": {} + } + ] + }, + { + "id": "bind_chat_message", + "type": "bind_event", + "title": "Bind: Chat On Message", + "position": { + "x": 2460, + "y": 880 + }, + "inputs": [ + { + "id": "exec_in", + "name": "", + "direction": "input", + "kind": "exec" + }, + { + "id": "target", + "name": "Target", + "direction": "input", + "kind": "object" + } + ], + "outputs": [ + { + "id": "exec_out", + "name": "", + "direction": "output", + "kind": "exec" + }, + { + "id": "event_out", + "name": "On Message", + "direction": "output", + "kind": "exec" + }, + { + "id": "username", + "name": "Username", + "direction": "output", + "kind": "string", + "defaultValue": "446" + }, + { + "id": "message", + "name": "Message", + "direction": "output", + "kind": "string", + "defaultValue": "quick" + } + ] + }, + { + "id": "append_message", + "type": "append", + "title": "Append", + "position": { + "x": 2700, + "y": 960 + }, + "inputs": [ + { + "id": "input1", + "name": "A", + "direction": "input", + "kind": "string", + "defaultValue": "" + }, + { + "id": "input2", + "name": "B", + "direction": "input", + "kind": "string", + "defaultValue": ": " + }, + { + "id": "input3", + "name": "C", + "direction": "input", + "kind": "string", + "defaultValue": "" + } + ], + "outputs": [ + { + "id": "result", + "name": "Result", + "direction": "output", + "kind": "string" + } + ] + }, + { + "id": "chat_message_print", + "type": "print", + "title": "Print Chat Message", + "position": { + "x": 2940, + "y": 900 + }, + "inputs": [ + { + "id": "exec_in", + "name": "", + "direction": "input", + "kind": "exec" + }, + { + "id": "value", + "name": "Value", + "direction": "input", + "kind": "string", + "defaultValue": "" + } + ], + "outputs": [ + { + "id": "exec_out", + "name": "", + "direction": "output", + "kind": "exec" + } + ] + } + ], + "connections": [ + { + "id": "f9df6643-9333-4aa1-8093-0a9b54a825ac", + "from": { + "nodeId": "node_custom_event", + "pinId": "exec_out" + }, + "to": { + "nodeId": "node_sequence", + "pinId": "exec_in" + }, + "kind": "exec" + }, + { + "id": "10861820-5e2d-468a-82fe-838bf90ce274", + "from": { + "nodeId": "node_sequence", + "pinId": "then_0" + }, + "to": { + "nodeId": "node_whoami", + "pinId": "exec_in" + }, + "kind": "exec" + }, + { + "id": "41accdc0-125d-4a2e-8695-db6e80230c19", + "from": { + "nodeId": "node_sequence", + "pinId": "then_1" + }, + "to": { + "nodeId": "socials", + "pinId": "exec_in" + }, + "kind": "exec" + }, + { + "id": "chat-connection", + "from": { + "nodeId": "node_sequence", + "pinId": "then_2" + }, + "to": { + "nodeId": "chat_node", + "pinId": "exec_in" + }, + "kind": "exec" + }, + { + "id": "homeserver-connection", + "from": { + "nodeId": "string_homeserver", + "pinId": "val_out" + }, + "to": { + "nodeId": "chat_node", + "pinId": "homeserver" + }, + "kind": "string" + }, + { + "id": "room-connection", + "from": { + "nodeId": "string_room", + "pinId": "val_out" + }, + "to": { + "nodeId": "chat_node", + "pinId": "room" + }, + "kind": "string" + }, + { + "id": "guest-name-connection", + "from": { + "nodeId": "random_name_generator", + "pinId": "name" + }, + "to": { + "nodeId": "chat_node", + "pinId": "guestName" + }, + "kind": "string" + }, + { + "id": "matrix-chat-to-node", + "from": { + "nodeId": "get_matrix_chat", + "pinId": "value" + }, + "to": { + "nodeId": "chat_node", + "pinId": "chat_object" + }, + "kind": "object" + }, + { + "id": "btn-to-connect", + "from": { + "nodeId": "btn_connect_chat", + "pinId": "exec_out" + }, + "to": { + "nodeId": "connect_chat_node", + "pinId": "exec_in" + }, + "kind": "exec" + }, + { + "id": "bind-event-exec", + "from": { + "nodeId": "bind_chat_message", + "pinId": "event_out" + }, + "to": { + "nodeId": "chat_message_print", + "pinId": "exec_in" + }, + "kind": "exec" + }, + { + "id": "bind-username-to-append", + "from": { + "nodeId": "bind_chat_message", + "pinId": "username" + }, + "to": { + "nodeId": "append_message", + "pinId": "input1" + }, + "kind": "string" + }, + { + "id": "bind-message-to-append", + "from": { + "nodeId": "bind_chat_message", + "pinId": "message" + }, + "to": { + "nodeId": "append_message", + "pinId": "input3" + }, + "kind": "string" + }, + { + "id": "bdff9556-2096-40ae-a9a9-342b3718ee76", + "from": { + "nodeId": "node_timer", + "pinId": "exec_out" + }, + "to": { + "nodeId": "node_print", + "pinId": "exec_in" + }, + "kind": "exec" + }, + { + "id": "3ed1cdfc-82e9-49cb-8af5-091287828967", + "from": { + "nodeId": "btn_run", + "pinId": "exec_out" + }, + "to": { + "nodeId": "node_print", + "pinId": "exec_in" + }, + "kind": "exec" + }, + { + "id": "8ad696ef-5177-4915-8982-951747c95174", + "from": { + "nodeId": "append_message", + "pinId": "result" + }, + "to": { + "nodeId": "chat_message_print", + "pinId": "value" + }, + "kind": "string" + }, + { + "id": "0c641c03-2e9b-48b8-b6b8-ce9d8377debc", + "from": { + "nodeId": "node_stringtest", + "pinId": "val_out" + }, + "to": { + "nodeId": "node_print", + "pinId": "value" + }, + "kind": "string" + }, + { + "id": "feac08f5-c62f-4a84-88d0-ee0f851a553a", + "from": { + "nodeId": "connect_chat_node", + "pinId": "exec_out" + }, + "to": { + "nodeId": "bind_chat_message", + "pinId": "exec_in" + }, + "kind": "exec" + }, + { + "id": "93b5a6d0-9d14-4b61-9337-11d315f9d1a6", + "from": { + "nodeId": "get_matrix_chat", + "pinId": "value" + }, + "to": { + "nodeId": "connect_chat_node", + "pinId": "target" + }, + "kind": "object" + }, + { + "id": "d8ce76ab-1cae-4e62-a5d9-208f07e33b02", + "from": { + "nodeId": "get_matrix_chat", + "pinId": "value" + }, + "to": { + "nodeId": "bind_chat_message", + "pinId": "target" + }, + "kind": "object" + } + ] +} \ No newline at end of file diff --git a/website/data/nameParts.json b/website/data/nameParts.json new file mode 100644 index 0000000..c88482a --- /dev/null +++ b/website/data/nameParts.json @@ -0,0 +1,26 @@ +{ + "firstParts": [ + "Unobtainable","Silent","Crimson","Electric","Feral","Cosmic","Hidden","Broken","Velvet","Rusty", + "Neon","Shattered","Golden","Frozen","Burning","Wicked","Lost","Ancient","Phantom","Savage", + "Twisted","Glitchy","Rapid","Lunar","Solar","Hollow","Cursed","Vivid","Blazing","Fuzzy", + "Static","Dynamic","Echoing","Radiant","Shadow","Icy","Stormy","Dusty","Wild","Chaotic", + "Prime","Obsidian","Crystal","Toxic","Grim","Lucid","Warped","Mellow","Slick","Gritty", + "Daring","Fatal","Nimble","Quantum","Pixel","Binary","Cyber","Turbo","Hyper","Mega", + "Ultra","Alpha","Omega","Delta","Nova","Zen","Mythic","Arcane","Ethereal","Infernal", + "Celestial","Divine","Void","Spectral","Iron","Steel","Bronze","Silver","Platinum","Titanium", + "Ghostly","Savory","Spicy","Sweet","Bitter","Zesty","Noisy","Quiet","Loud","Soft", + "Curious","Clever","Brutal","Gentle","Kind","Ruthless","Lucky","Unlucky","Faded","Bright" + ], + "secondParts": [ + "Orange","Tiger","Falcon","Wizard","Knight","Samurai","Pirate","Ninja","Dragon","Phoenix", + "Wolf","Bear","Shark","Eagle","Lion","Panther","Viper","Cobra","Raven","Fox", + "Otter","Badger","Hawk","Crow","Dolphin","Whale","Kraken","Leviathan","Hydra","Griffin", + "Unicorn","Pegasus","Golem","Goblin","Orc","Troll","Elf","Dwarf","Mage","Warlock", + "Hunter","Sniper","Assassin","Guardian","Wanderer","Nomad","Outlaw","Bandit","Rogue","Mercenary", + "Captain","Pilot","Driver","Rider","Runner","Jumper","Climber","Diver","Surfer","Skater", + "Coder","Hacker","Debugger","Builder","Crafter","Smith","Engineer","Architect","Designer","Artist", + "Painter","Singer","Drummer","Gamer","Streamer","Player","Champion","Legend","Hero","Villain", + "Boss","King","Queen","Jester","Clown","Monk","Priest","Scholar","Sage","Oracle", + "Prophet","Seeker","Finder","Keeper","Watcher","Observer","Listener","Speaker","Caller","Breaker" + ] +} diff --git a/website/data/pop.mp3 b/website/data/pop.mp3 new file mode 100644 index 0000000..a37573b Binary files /dev/null and b/website/data/pop.mp3 differ diff --git a/website/data/socials.md b/website/data/socials.md new file mode 100644 index 0000000..d12f244 --- /dev/null +++ b/website/data/socials.md @@ -0,0 +1,7 @@ +[litruv](https://youtube.com/litruv) + +[litruv](https://github.com/litruv) + +[@lit.mates.dev](https://bsky.app/profile/lit.mates.dev) + +[@lit:ruv.wtf](https://matrix.to/#/@lit:ruv.wtf) \ No newline at end of file diff --git a/website/data/whoami.md b/website/data/whoami.md new file mode 100644 index 0000000..3959071 --- /dev/null +++ b/website/data/whoami.md @@ -0,0 +1,23 @@ +# *G'day*, I'm Litruv + +A **developer** who builds weirdly specific tools, game systems, and whatever else seems interesting at 2am. + +## What I Do + +- Build random tools out of pure curiosity (half of them probably shouldn't exist) +- Automate anything. +- Mess with game systems, plugins and engine internals until they behave. +- Occasionally finish things. + +## Stack + +`Unreal Engine` · `JavaScript` · `HTML/CSS` · `C#` + +## Currently Working On + +- Unreal Engine plugins (logic systems, modding support, weird runtime stuff) +- Docs Viewer with zero backend. +- Audio tools (VST's) +- Random experiments that spiral into main projects. + +> *"Make something you'd actually want to use."* diff --git a/website/data/zoom_in.json b/website/data/zoom_in.json new file mode 100644 index 0000000..a3e3627 --- /dev/null +++ b/website/data/zoom_in.json @@ -0,0 +1 @@ +{"v":"5.7.13","fr":30,"ip":0,"op":75,"w":2000,"h":2000,"nm":"Lottie - hand 03 pinch zoom out 2","ddd":0,"assets":[{"id":"comp_0","nm":"arrow pink 2","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"line","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-63.035,0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.001,"y":0},"t":0,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[44.445,-0.025],[53.555,0]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":17,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-104.555,-0.174],[53.555,0]],"c":false}]},{"t":30,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[26.445,-0.374],[53.555,0]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.886274569642,0.556862745098,0.960784373564,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":39,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":41,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"arrow 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.001,"y":0},"t":0,"s":[527.036,252.975,0],"to":[0,0,0],"ti":[0,0,0]},{"t":30,"s":[767.036,252.975,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[7.625,7.596],[0,0],[-7.748,7.624],[0,0],[0,0],[0,0],[-7.748,7.625],[-7.625,-7.596],[0,0]],"o":[[-7.749,7.625],[0,0],[-7.625,-7.596],[0,0],[0,0],[0,0],[-7.624,-7.596],[7.75,-7.624],[0,0],[0,0]],"v":[[-15.444,63.2],[-43.28,63.251],[-43.28,63.251],[-43.055,35.692],[-26.582,19.483],[-26.263,-19.492],[-42.472,-35.641],[-42.247,-63.2],[-14.41,-63.251],[48.933,-0.143]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.556862771511,0.960784316063,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":41,"st":0,"bm":0}]},{"id":"comp_1","nm":"touch - pink 2","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0],"y":[0]},"t":0,"s":[39]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":15,"s":[100]},{"i":{"x":[1],"y":[1]},"o":{"x":[1],"y":[0]},"t":23,"s":[100]},{"t":36,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[750,750,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0,0,0.667],"y":[1,1,1]},"o":{"x":[0,0,0.333],"y":[0,0,0]},"t":0,"s":[0,0,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":15,"s":[192,192,100]},{"i":{"x":[1,1,0.833],"y":[1,1,1]},"o":{"x":[1,1,0.333],"y":[0,0,0]},"t":23,"s":[192,192,100]},{"t":36,"s":[291,291,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[500,500],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274509804,0.556862745098,0.960784313725,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":213,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Layer 2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.001],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.999],"y":[1]},"o":{"x":[0.001],"y":[0]},"t":20,"s":[100]},{"i":{"x":[0.999],"y":[1]},"o":{"x":[1],"y":[0]},"t":43,"s":[100]},{"t":73,"s":[0]}],"ix":11},"r":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.001],"y":[0]},"t":0,"s":[-55.581]},{"i":{"x":[0],"y":[1]},"o":{"x":[0.7],"y":[0]},"t":30,"s":[18.004]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.7],"y":[0]},"t":43,"s":[0]},{"t":73,"s":[26]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.001,"y":0},"t":0,"s":[960,1714,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0,"y":0},"o":{"x":0.63,"y":0.63},"t":20,"s":[960,1434,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":1,"y":1},"o":{"x":1,"y":0},"t":43,"s":[960,1434,0],"to":[0,0,0],"ti":[0,0,0]},{"t":73,"s":[960,1874,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-40,434,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0,0,0],"y":[1,1,1]},"o":{"x":[0.001,0.001,0.001],"y":[0,0,0]},"t":20,"s":[116,116,100]},{"i":{"x":[0,0,0],"y":[1,1,1]},"o":{"x":[0.7,0.7,0.7],"y":[0,0,0]},"t":30,"s":[85,85,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.7,0.7,0.7],"y":[0,0,0]},"t":43,"s":[85,85,100]},{"t":73,"s":[100,100,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.167,"y":0},"t":11,"s":[{"i":[[7.232,-0.124],[13.75,-77.473],[-1.257,-22.237],[4.063,-3.363],[11.108,18.336],[50.834,224.11],[24.093,-4.716],[-2.92,-19.01],[0,0],[5.418,-1.209],[0,0],[3.291,-18.175],[0,0],[0,0],[2.578,-33.821],[0,0],[7.537,-24.076],[0,0],[-16.971,-40.225],[0,0],[5.025,-40.315],[0,0],[-6.251,-0.78],[-0.471,0],[-0.718,5.772],[0,0],[17.346,41.116],[0,0],[-11.432,36.519],[0,0],[-11.344,8.158],[0,0],[-6.283,-0.479],[-0.291,0],[-0.456,5.986],[0,0],[0.139,1.396],[0,0],[-12.655,17.723],[-1.928,-17.092],[0,0],[-5.727,0],[-0.435,0.049],[0.707,6.26],[0,0],[-5.003,27.636],[0,0],[-9.049,2.02],[0,0],[-4.682,-2.295],[0,0],[-6.212,0.95],[0.957,6.227],[0,0],[-6.832,1.272],[-2.709,-11.943],[-20.473,-38.402],[-22.222,-3.111],[2.617,27.124],[0.96,17.016],[-3.299,18.597],[-13.953,0.241],[-0.313,-0.363],[0.104,-0.624],[-12.435,-42.703],[12.457,-18.395],[0,0],[1.032,-27.926],[0,0],[-6.294,-0.232],[-0.143,0],[-0.228,6.151],[0,0],[-13.268,19.594],[0,0],[5.724,19.659],[-8.958,53.347],[4.762,5.53]],"o":[[-26.517,0.456],[-3.608,20.333],[0.988,17.47],[-3.689,3.053],[-14.27,-23.556],[-5.454,-24.057],[-18.91,3.516],[0,0],[-5.366,-0.744],[0,0],[-18.027,4.023],[0,0],[0,0],[-23.901,24.067],[0,0],[-22.836,10.231],[0,0],[-13.042,41.667],[0,0],[15.793,37.437],[0,0],[-0.779,6.251],[0.479,0.061],[5.671,0],[0,0],[5.52,-44.28],[0,0],[-14.875,-35.258],[0,0],[4.253,-13.59],[0,0],[-0.479,6.28],[0.295,0.022],[5.906,0],[0,0],[0.356,-1.315],[0,0],[1.672,-21.936],[-0.908,17.114],[0,0],[0.657,5.826],[0.428,0],[6.26,-0.707],[0,0],[-3.149,-27.915],[0,0],[1.652,-9.123],[0,0],[5.132,-1.145],[0,0],[0.956,6.229],[6.227,-0.958],[0,0],[-1.038,-6.759],[12.023,-2.366],[31.221,137.647],[9.662,18.122],[2.467,0.345],[-1.288,-13.361],[-1.158,-20.475],[12.056,-67.936],[0.454,-0.008],[0.232,0.269],[-9.707,57.824],[6.151,21.127],[0,0],[-15.667,23.136],[0,0],[-0.233,6.296],[0.144,0.007],[6.105,0],[0,0],[0.873,-23.65],[0,0],[11.598,-17.13],[-11.887,-40.829],[1.205,-7.187],[-4.735,-5.5]],"v":[[178.414,-176.332],[101.307,-88.833],[102.287,-35.322],[117.572,1.928],[101.575,-11.993],[18.3,-239.998],[-34.499,-274.455],[-63.013,-234.28],[-65.515,-242.104],[-81.8,-241.463],[-90.086,-239.612],[-125.179,-203.068],[-128.937,-182.303],[-137.189,-173.993],[-178.252,-84.224],[-178.522,-80.695],[-226.595,-26.708],[-233.741,-3.881],[-227.65,123.11],[-192.847,205.609],[-176.388,324.458],[-190.32,436.249],[-180.412,448.979],[-178.987,449.068],[-167.683,439.069],[-153.751,327.278],[-171.828,196.741],[-206.631,114.244],[-211.97,2.936],[-204.824,-19.891],[-180.607,-53.348],[-180.951,-48.839],[-170.444,-36.599],[-169.565,-36.566],[-158.204,-47.106],[-156.469,-69.872],[-156.156,-73.971],[-155.506,-82.491],[-133.473,-143.099],[-131.996,-91.714],[-128.29,-58.867],[-116.97,-48.739],[-115.677,-48.81],[-105.622,-61.424],[-109.328,-94.272],[-106.534,-177.989],[-102.731,-199.005],[-85.115,-217.347],[-76.83,-219.198],[-61.721,-217.402],[-46.812,-120.329],[-33.807,-110.785],[-24.264,-123.79],[-40.465,-237.742],[-30.219,-252.047],[-3.947,-234.953],[83.91,2.444],[115.794,23.677],[139.638,-3.473],[125.065,-36.608],[123.769,-84.848],[178.807,-153.522],[179.963,-152.986],[180.345,-151.64],[219.158,19.488],[172.958,116.296],[109.079,210.635],[83.554,288.688],[78.072,437.238],[89.05,449.057],[89.479,449.065],[100.87,438.08],[106.352,289.529],[127.968,223.427],[191.847,129.088],[241.06,13.108],[202.844,-147.864],[197.249,-167.873]],"c":true}]},{"i":{"x":0,"y":1},"o":{"x":0.7,"y":0},"t":30,"s":[{"i":[[7.232,-0.125],[13.75,-77.473],[-1.257,-22.237],[4.063,-3.363],[11.108,18.336],[50.834,224.11],[24.093,-4.716],[-2.92,-19.01],[0,0],[5.418,-1.209],[0,0],[3.291,-18.175],[0,0],[0,0],[2.578,-33.821],[0,0],[7.537,-24.076],[0,0],[-16.971,-40.225],[0,0],[5.025,-40.315],[0,0],[-6.251,-0.78],[-0.471,0],[-0.718,5.772],[0,0],[17.346,41.115],[0,0],[-11.432,36.519],[0,0],[-11.344,8.158],[0,0],[-6.283,-0.479],[-0.291,0],[-0.456,5.986],[0,0],[0.139,1.396],[0,0],[-12.655,17.723],[-1.928,-17.092],[0,0],[-5.727,0],[-0.435,0.049],[0.707,6.26],[0,0],[-5.003,27.636],[0,0],[-9.049,2.02],[0,0],[-4.682,-2.295],[0,0],[-6.212,0.95],[0.957,6.227],[0,0],[-6.832,1.272],[-2.709,-11.943],[-20.473,-38.402],[-22.222,-3.111],[2.617,27.124],[0.96,17.016],[-3.299,18.597],[-13.953,0.241],[-0.313,-0.363],[0.104,-0.624],[-12.435,-42.703],[12.457,-18.395],[0,0],[1.032,-27.926],[0,0],[-6.294,-0.232],[-0.143,0],[-0.228,6.151],[0,0],[-13.268,19.594],[0,0],[5.724,19.659],[-8.958,53.347],[4.762,5.53]],"o":[[-26.517,0.456],[-3.608,20.333],[0.988,17.47],[-3.689,3.053],[-14.27,-23.556],[-5.454,-24.057],[-18.91,3.516],[0,0],[-5.366,-0.744],[0,0],[-18.027,4.023],[0,0],[0,0],[-23.901,24.067],[0,0],[-22.836,10.23],[0,0],[-13.042,41.667],[0,0],[15.793,37.437],[0,0],[-0.779,6.251],[0.479,0.061],[5.671,0],[0,0],[5.52,-44.28],[0,0],[-14.875,-35.258],[0,0],[4.253,-13.59],[0,0],[-0.479,6.28],[0.295,0.022],[5.906,0],[0,0],[0.356,-1.314],[0,0],[1.672,-21.936],[-0.908,17.114],[0,0],[0.657,5.826],[0.428,0],[6.26,-0.707],[0,0],[-3.149,-27.915],[0,0],[1.652,-9.123],[0,0],[5.132,-1.145],[0,0],[0.956,6.229],[6.227,-0.958],[0,0],[-1.038,-6.759],[12.023,-2.366],[31.221,137.647],[9.662,18.122],[2.467,0.345],[-1.288,-13.361],[-1.158,-20.475],[12.056,-67.936],[0.454,-0.008],[0.232,0.27],[-9.707,57.824],[6.151,21.127],[0,0],[-15.667,23.136],[0,0],[-0.233,6.296],[0.144,0.007],[6.105,0],[0,0],[0.873,-23.65],[0,0],[11.598,-17.13],[-11.887,-40.829],[1.205,-7.187],[-4.735,-5.5]],"v":[[195.09,-185.999],[117.983,-98.501],[114.439,-34.345],[117.572,1.928],[101.575,-11.993],[2.977,-320.24],[-49.822,-354.697],[-78.336,-314.522],[-65.515,-242.104],[-81.8,-241.463],[-90.086,-239.612],[-125.179,-203.068],[-128.937,-182.303],[-137.189,-173.993],[-178.252,-84.224],[-178.522,-80.695],[-226.595,-26.708],[-233.741,-3.881],[-227.65,123.11],[-192.847,205.608],[-176.388,324.457],[-190.32,436.248],[-180.412,448.978],[-178.987,449.067],[-167.683,439.069],[-153.751,327.277],[-171.828,196.741],[-206.631,114.244],[-211.97,2.936],[-204.824,-19.891],[-180.607,-53.348],[-180.951,-48.839],[-170.444,-36.599],[-169.565,-36.566],[-158.204,-47.106],[-156.469,-69.872],[-156.156,-73.971],[-155.506,-82.491],[-133.473,-143.099],[-131.996,-91.714],[-128.29,-58.867],[-116.97,-48.739],[-115.677,-48.81],[-105.622,-61.424],[-109.328,-94.271],[-106.534,-177.989],[-102.731,-199.005],[-85.115,-217.347],[-76.83,-219.198],[-61.721,-217.402],[-46.812,-120.329],[-33.807,-110.785],[-24.264,-123.79],[-55.788,-317.984],[-45.542,-332.289],[-19.271,-315.195],[83.91,2.444],[115.794,23.677],[139.638,-3.473],[137.217,-35.632],[140.446,-94.516],[195.483,-163.19],[196.64,-162.654],[197.022,-161.308],[219.158,19.488],[172.958,116.296],[109.079,210.634],[83.554,288.687],[78.072,437.237],[89.05,449.056],[89.479,449.065],[100.87,438.079],[106.352,289.529],[127.968,223.427],[191.847,129.088],[241.06,13.108],[219.521,-157.532],[213.926,-177.541]],"c":true}]},{"i":{"x":0,"y":1},"o":{"x":0.7,"y":0},"t":43,"s":[{"i":[[3.518,6.32],[74.812,-24.377],[19.021,-11.587],[11.354,-7.548],[16.326,19.975],[50.834,224.11],[24.093,-4.716],[-2.921,-19.01],[0,0],[5.418,-1.21],[0,0],[3.291,-18.175],[0,0],[0,0],[2.578,-33.821],[0,0],[7.537,-24.076],[0,0],[-16.971,-40.226],[0,0],[5.025,-40.315],[0,0],[-6.251,-0.78],[-0.471,0],[-0.718,5.772],[0,0],[17.346,41.115],[0,0],[-11.432,36.519],[0,0],[-11.344,8.158],[0,0],[-6.283,-0.479],[-0.291,0],[-0.456,5.986],[0,0],[0.139,1.397],[0,0],[-12.655,17.723],[-1.928,-17.092],[0,0],[-5.727,0],[-0.434,0.049],[0.706,6.26],[0,0],[-5.003,27.636],[0,0],[-9.05,2.021],[0,0],[-4.682,-2.295],[0,0],[-6.212,0.949],[0.957,6.227],[0,0],[-6.832,1.272],[-2.709,-11.944],[-27.54,-33.696],[-18.637,4.576],[-22.691,15.089],[-14.556,8.865],[-17.958,5.853],[-6.787,-12.193],[0.173,-0.448],[0.599,-0.203],[31.806,-31.09],[12.457,-18.395],[0,0],[1.032,-27.926],[0,0],[-6.295,-0.232],[-0.143,0],[-0.227,6.151],[0,0],[-13.268,19.594],[0,0],[-14.642,14.312],[-51.274,17.237],[-2.633,6.806]],"o":[[-12.897,-23.174],[-19.635,6.398],[-14.943,9.103],[-46.871,31.163],[-17.428,-21.325],[-5.455,-24.056],[-18.91,3.516],[0,0],[-5.366,-0.744],[0,0],[-18.027,4.024],[0,0],[0,0],[-23.901,24.067],[0,0],[-22.836,10.23],[0,0],[-13.043,41.667],[0,0],[15.793,37.437],[0,0],[-0.78,6.251],[0.479,0.06],[5.671,0],[0,0],[5.52,-44.281],[0,0],[-14.875,-35.258],[0,0],[4.253,-13.59],[0,0],[-0.479,6.28],[0.295,0.022],[5.906,0],[0,0],[0.357,-1.314],[0,0],[1.672,-21.936],[-0.908,17.114],[0,0],[0.657,5.826],[0.428,0],[6.26,-0.706],[0,0],[-3.149,-27.915],[0,0],[1.652,-9.123],[0,0],[5.132,-1.145],[0,0],[0.956,6.229],[6.227,-0.958],[0,0],[-1.038,-6.759],[12.024,-2.366],[31.221,137.647],[14.462,17.694],[15.545,-3.819],[11.178,-7.432],[17.514,-10.669],[65.602,-21.378],[0.22,0.396],[-0.128,0.332],[-55.576,18.685],[-15.737,15.381],[0,0],[-15.667,23.136],[0,0],[-0.233,6.296],[0.144,0.007],[6.106,0],[0,0],[0.873,-23.651],[0,0],[11.598,-17.13],[30.411,-29.724],[6.907,-2.324],[2.62,-6.768]],"v":[[329.113,45.883],[215.604,19.102],[157.347,46.207],[118.181,71.415],[45.336,86.553],[-65.331,-311.349],[-118.13,-345.806],[-146.644,-305.631],[-121.753,-143.559],[-138.039,-142.918],[-146.325,-141.066],[-181.417,-104.523],[-185.176,-83.757],[-193.428,-75.447],[-234.491,14.321],[-234.761,17.85],[-282.833,71.838],[-289.979,94.664],[-283.889,221.656],[-249.086,304.153],[-232.626,423.002],[-246.558,534.793],[-236.65,547.523],[-235.225,547.612],[-223.921,537.614],[-209.989,425.823],[-228.066,295.286],[-262.87,212.789],[-268.209,101.482],[-261.063,78.655],[-236.846,45.197],[-237.189,49.707],[-226.682,61.946],[-225.804,61.98],[-214.443,51.44],[-212.707,28.673],[-212.394,24.574],[-211.745,16.055],[-189.712,-44.554],[-188.234,6.831],[-184.528,39.679],[-173.209,49.807],[-171.915,49.735],[-161.86,37.121],[-165.566,4.274],[-162.772,-79.444],[-158.969,-100.459],[-141.354,-118.801],[-133.069,-120.652],[-117.959,-118.857],[-103.051,-21.783],[-90.045,-12.239],[-80.502,-25.245],[-124.096,-309.093],[-113.85,-323.398],[-87.578,-306.303],[27.672,100.989],[76.183,120.218],[130.812,90.411],[169.215,65.691],[222.673,40.793],[309.179,56.978],[309.252,58.25],[308.245,59.222],[159.208,163.937],[116.719,214.841],[52.84,309.179],[27.315,387.232],[21.834,535.782],[32.811,547.601],[33.24,547.61],[44.631,536.625],[50.113,388.074],[71.73,321.972],[135.609,227.633],[175.155,180.249],[315.515,80.845],[330.528,66.482]],"c":true}]},{"t":104,"s":[{"i":[[7.232,-0.125],[13.75,-77.473],[-1.257,-22.237],[4.063,-3.363],[11.108,18.336],[50.834,224.11],[24.093,-4.716],[-2.92,-19.01],[0,0],[5.418,-1.209],[0,0],[3.291,-18.175],[0,0],[0,0],[2.578,-33.821],[0,0],[7.537,-24.076],[0,0],[-16.971,-40.225],[0,0],[5.025,-40.315],[0,0],[-6.251,-0.78],[-0.471,0],[-0.718,5.772],[0,0],[17.346,41.115],[0,0],[-11.432,36.519],[0,0],[-11.344,8.158],[0,0],[-6.283,-0.479],[-0.291,0],[-0.456,5.986],[0,0],[0.139,1.396],[0,0],[-12.655,17.723],[-1.928,-17.092],[0,0],[-5.727,0],[-0.435,0.049],[0.707,6.26],[0,0],[-5.003,27.636],[0,0],[-9.049,2.02],[0,0],[-4.682,-2.295],[0,0],[-6.212,0.95],[0.957,6.227],[0,0],[-6.832,1.272],[-2.709,-11.943],[-20.473,-38.402],[-22.222,-3.111],[2.617,27.124],[0.96,17.016],[-3.299,18.597],[-13.953,0.241],[-0.313,-0.363],[0.104,-0.624],[-12.435,-42.703],[12.457,-18.395],[0,0],[1.032,-27.926],[0,0],[-6.294,-0.232],[-0.143,0],[-0.228,6.151],[0,0],[-13.268,19.594],[0,0],[5.724,19.659],[-8.958,53.347],[4.762,5.53]],"o":[[-26.517,0.456],[-3.608,20.333],[0.988,17.47],[-3.689,3.053],[-14.27,-23.556],[-5.454,-24.057],[-18.91,3.516],[0,0],[-5.366,-0.744],[0,0],[-18.027,4.023],[0,0],[0,0],[-23.901,24.067],[0,0],[-22.836,10.23],[0,0],[-13.042,41.667],[0,0],[15.793,37.437],[0,0],[-0.779,6.251],[0.479,0.061],[5.671,0],[0,0],[5.52,-44.28],[0,0],[-14.875,-35.258],[0,0],[4.253,-13.59],[0,0],[-0.479,6.28],[0.295,0.022],[5.906,0],[0,0],[0.356,-1.314],[0,0],[1.672,-21.936],[-0.908,17.114],[0,0],[0.657,5.826],[0.428,0],[6.26,-0.707],[0,0],[-3.149,-27.915],[0,0],[1.652,-9.123],[0,0],[5.132,-1.145],[0,0],[0.956,6.229],[6.227,-0.958],[0,0],[-1.038,-6.759],[12.023,-2.366],[31.221,137.647],[9.662,18.122],[2.467,0.345],[-1.288,-13.361],[-1.158,-20.475],[12.056,-67.936],[0.454,-0.008],[0.232,0.27],[-9.707,57.824],[6.151,21.127],[0,0],[-15.667,23.136],[0,0],[-0.233,6.296],[0.144,0.007],[6.105,0],[0,0],[0.873,-23.65],[0,0],[11.598,-17.13],[-11.887,-40.829],[1.205,-7.187],[-4.735,-5.5]],"v":[[195.09,-185.999],[117.983,-98.501],[114.439,-34.345],[117.572,1.928],[101.575,-11.993],[2.977,-320.24],[-49.822,-354.697],[-78.336,-314.522],[-65.515,-242.104],[-81.8,-241.463],[-90.086,-239.612],[-125.179,-203.068],[-128.937,-182.303],[-137.189,-173.993],[-178.252,-84.224],[-178.522,-80.695],[-226.595,-26.708],[-233.741,-3.881],[-227.65,123.11],[-192.847,205.608],[-176.388,324.457],[-190.32,436.248],[-180.412,448.978],[-178.987,449.067],[-167.683,439.069],[-153.751,327.277],[-171.828,196.741],[-206.631,114.244],[-211.97,2.936],[-204.824,-19.891],[-180.607,-53.348],[-180.951,-48.839],[-170.444,-36.599],[-169.565,-36.566],[-158.204,-47.106],[-156.469,-69.872],[-156.156,-73.971],[-155.506,-82.491],[-133.473,-143.099],[-131.996,-91.714],[-128.29,-58.867],[-116.97,-48.739],[-115.677,-48.81],[-105.622,-61.424],[-109.328,-94.271],[-106.534,-177.989],[-102.731,-199.005],[-85.115,-217.347],[-76.83,-219.198],[-61.721,-217.402],[-46.812,-120.329],[-33.807,-110.785],[-24.264,-123.79],[-55.788,-317.984],[-45.542,-332.289],[-19.271,-315.195],[83.91,2.444],[115.794,23.677],[139.638,-3.473],[137.217,-35.632],[140.446,-94.516],[195.483,-163.19],[196.64,-162.654],[197.022,-161.308],[219.158,19.488],[172.958,116.296],[109.079,210.634],[83.554,288.687],[78.072,437.237],[89.05,449.056],[89.479,449.065],[100.87,438.079],[106.352,289.529],[127.968,223.427],[191.847,129.088],[241.06,13.108],[219.521,-157.532],[213.926,-177.541]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.188235294118,0.309803921569,0.305882352941,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":83,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Layer 4","parent":1,"sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.001],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.999],"y":[1]},"o":{"x":[0.001],"y":[0]},"t":20,"s":[100]},{"i":{"x":[0.999],"y":[1]},"o":{"x":[1],"y":[0]},"t":43,"s":[100]},{"t":73,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-40,434,0],"ix":2,"l":2},"a":{"a":0,"k":[-40,434,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.167,"y":0},"t":11,"s":[{"i":[[7.232,-0.124],[13.75,-77.473],[-1.257,-22.237],[4.063,-3.363],[11.108,18.336],[50.834,224.11],[24.093,-4.716],[-2.92,-19.01],[0,0],[5.418,-1.209],[0,0],[3.291,-18.175],[0,0],[0,0],[2.578,-33.821],[0,0],[7.537,-24.076],[0,0],[-16.971,-40.225],[0,0],[5.025,-40.315],[0,0],[-6.251,-0.78],[-0.471,0],[-6.294,-0.232],[-0.143,0],[-0.228,6.151],[0,0],[-13.268,19.594],[0,0],[5.724,19.659],[-8.958,53.347],[4.762,5.53]],"o":[[-26.517,0.456],[-3.608,20.333],[0.988,17.47],[-3.689,3.053],[-14.27,-23.556],[-5.454,-24.057],[-18.91,3.516],[0,0],[-5.366,-0.744],[0,0],[-18.027,4.023],[0,0],[0,0],[-23.901,24.067],[0,0],[-22.836,10.231],[0,0],[-13.042,41.667],[0,0],[15.793,37.437],[0,0],[-0.779,6.251],[0.479,0.061],[5.671,0],[0.144,0.007],[6.105,0],[0,0],[0.873,-23.65],[0,0],[11.598,-17.13],[-11.887,-40.829],[1.205,-7.187],[-4.735,-5.5]],"v":[[178.414,-176.332],[101.307,-88.833],[102.287,-35.322],[117.572,1.928],[101.575,-11.993],[18.3,-239.998],[-34.499,-274.455],[-63.013,-234.28],[-65.515,-242.104],[-81.8,-241.463],[-90.086,-239.612],[-125.179,-203.068],[-128.937,-182.303],[-137.189,-173.993],[-178.252,-84.224],[-178.522,-80.695],[-226.595,-26.708],[-233.741,-3.881],[-227.65,123.11],[-192.847,205.609],[-176.388,324.458],[-190.32,436.249],[-180.412,448.979],[-178.987,449.068],[89.05,449.057],[89.479,449.065],[100.87,438.08],[106.352,289.529],[127.968,223.427],[191.847,129.088],[241.06,13.108],[202.844,-147.864],[197.249,-167.873]],"c":true}]},{"i":{"x":0,"y":1},"o":{"x":0.7,"y":0},"t":30,"s":[{"i":[[7.232,-0.125],[13.75,-77.473],[-1.257,-22.237],[4.063,-3.363],[11.108,18.336],[50.834,224.11],[24.093,-4.716],[-2.92,-19.01],[0,0],[5.418,-1.209],[0,0],[3.291,-18.175],[0,0],[0,0],[2.578,-33.821],[0,0],[7.537,-24.076],[0,0],[-16.971,-40.225],[0,0],[5.025,-40.315],[0,0],[-6.251,-0.78],[-0.471,0],[-6.294,-0.232],[-0.143,0],[-0.228,6.151],[0,0],[-13.268,19.594],[0,0],[5.724,19.659],[-8.958,53.347],[4.762,5.53]],"o":[[-26.517,0.456],[-3.608,20.333],[0.988,17.47],[-3.689,3.053],[-14.27,-23.556],[-5.454,-24.057],[-18.91,3.516],[0,0],[-5.366,-0.744],[0,0],[-18.027,4.023],[0,0],[0,0],[-23.901,24.067],[0,0],[-22.836,10.23],[0,0],[-13.042,41.667],[0,0],[15.793,37.437],[0,0],[-0.779,6.251],[0.479,0.061],[5.671,0],[0.144,0.007],[6.105,0],[0,0],[0.873,-23.65],[0,0],[11.598,-17.13],[-11.887,-40.829],[1.205,-7.187],[-4.735,-5.5]],"v":[[195.09,-185.999],[117.983,-98.501],[114.439,-34.345],[117.572,1.928],[101.575,-11.993],[2.977,-320.24],[-49.822,-354.697],[-78.336,-314.522],[-65.515,-242.104],[-81.8,-241.463],[-90.086,-239.612],[-125.179,-203.068],[-128.937,-182.303],[-137.189,-173.993],[-178.252,-84.224],[-178.522,-80.695],[-226.595,-26.708],[-233.741,-3.881],[-227.65,123.11],[-192.847,205.608],[-176.388,324.457],[-190.32,436.248],[-180.412,448.978],[-178.987,449.067],[89.05,449.056],[89.479,449.065],[100.87,438.079],[106.352,289.529],[127.968,223.427],[191.847,129.088],[241.06,13.108],[219.521,-157.532],[213.926,-177.541]],"c":true}]},{"i":{"x":0,"y":1},"o":{"x":0.7,"y":0},"t":43,"s":[{"i":[[3.518,6.32],[74.812,-24.377],[19.021,-11.587],[11.354,-7.548],[16.326,19.975],[50.834,224.11],[24.093,-4.716],[-2.921,-19.01],[0,0],[5.418,-1.21],[0,0],[3.291,-18.175],[0,0],[0,0],[2.578,-33.821],[0,0],[7.537,-24.076],[0,0],[-16.971,-40.226],[0,0],[5.025,-40.315],[0,0],[-6.251,-0.78],[-0.471,0],[-6.295,-0.232],[-0.143,0],[-0.227,6.151],[0,0],[-13.268,19.594],[0,0],[-14.642,14.312],[-51.274,17.237],[-2.633,6.806]],"o":[[-12.897,-23.174],[-19.635,6.398],[-14.943,9.103],[-46.871,31.163],[-17.428,-21.325],[-5.455,-24.056],[-18.91,3.516],[0,0],[-5.366,-0.744],[0,0],[-18.027,4.024],[0,0],[0,0],[-23.901,24.067],[0,0],[-22.836,10.23],[0,0],[-13.043,41.667],[0,0],[15.793,37.437],[0,0],[-0.78,6.251],[0.479,0.06],[5.671,0],[0.144,0.007],[6.106,0],[0,0],[0.873,-23.651],[0,0],[11.598,-17.13],[30.411,-29.724],[6.907,-2.324],[2.62,-6.768]],"v":[[329.113,45.883],[215.604,19.102],[157.347,46.207],[118.181,71.415],[45.336,86.553],[-65.331,-311.349],[-118.13,-345.806],[-146.644,-305.631],[-121.753,-143.559],[-138.039,-142.918],[-146.325,-141.066],[-181.417,-104.523],[-185.176,-83.757],[-193.428,-75.447],[-234.491,14.321],[-234.761,17.85],[-282.833,71.838],[-289.979,94.664],[-283.889,221.656],[-249.086,304.153],[-232.626,423.002],[-246.558,534.793],[-236.65,547.523],[-235.225,547.612],[32.811,547.601],[33.24,547.61],[44.631,536.625],[50.113,388.074],[71.73,321.972],[135.609,227.633],[175.155,180.249],[315.515,80.845],[330.528,66.482]],"c":true}]},{"t":104,"s":[{"i":[[7.232,-0.125],[13.75,-77.473],[-1.257,-22.237],[4.063,-3.363],[11.108,18.336],[50.834,224.11],[24.093,-4.716],[-2.92,-19.01],[0,0],[5.418,-1.209],[0,0],[3.291,-18.175],[0,0],[0,0],[2.578,-33.821],[0,0],[7.537,-24.076],[0,0],[-16.971,-40.225],[0,0],[5.025,-40.315],[0,0],[-6.251,-0.78],[-0.471,0],[-6.294,-0.232],[-0.143,0],[-0.228,6.151],[0,0],[-13.268,19.594],[0,0],[5.724,19.659],[-8.958,53.347],[4.762,5.53]],"o":[[-26.517,0.456],[-3.608,20.333],[0.988,17.47],[-3.689,3.053],[-14.27,-23.556],[-5.454,-24.057],[-18.91,3.516],[0,0],[-5.366,-0.744],[0,0],[-18.027,4.023],[0,0],[0,0],[-23.901,24.067],[0,0],[-22.836,10.23],[0,0],[-13.042,41.667],[0,0],[15.793,37.437],[0,0],[-0.779,6.251],[0.479,0.061],[5.671,0],[0.144,0.007],[6.105,0],[0,0],[0.873,-23.65],[0,0],[11.598,-17.13],[-11.887,-40.829],[1.205,-7.187],[-4.735,-5.5]],"v":[[195.09,-185.999],[117.983,-98.501],[114.439,-34.345],[117.572,1.928],[101.575,-11.993],[2.977,-320.24],[-49.822,-354.697],[-78.336,-314.522],[-65.515,-242.104],[-81.8,-241.463],[-90.086,-239.612],[-125.179,-203.068],[-128.937,-182.303],[-137.189,-173.993],[-178.252,-84.224],[-178.522,-80.695],[-226.595,-26.708],[-233.741,-3.881],[-227.65,123.11],[-192.847,205.608],[-176.388,324.457],[-190.32,436.248],[-180.412,448.978],[-178.987,449.067],[89.05,449.056],[89.479,449.065],[100.87,438.079],[106.352,289.529],[127.968,223.427],[191.847,129.088],[241.06,13.108],[219.521,-157.532],[213.926,-177.541]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.976470588235,0.976470588235,0.976470588235,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":74,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"arrow pink 2","parent":4,"refId":"comp_0","sr":0.8,"ks":{"o":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.001],"y":[0]},"t":36,"s":[0]},{"i":{"x":[0.999],"y":[1]},"o":{"x":[0.001],"y":[0]},"t":40,"s":[100]},{"i":{"x":[0.999],"y":[1]},"o":{"x":[1],"y":[0]},"t":52,"s":[100]},{"t":60,"s":[0]}],"ix":11},"r":{"a":0,"k":180,"ix":10},"p":{"a":0,"k":[500,250,0],"ix":2,"l":2},"a":{"a":0,"k":[500,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.78823530674,0.921568632126,0,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":1000,"h":500,"ip":36,"op":68.8,"st":36,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"arrow pink 2","parent":7,"refId":"comp_0","sr":0.8,"ks":{"o":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.001],"y":[0]},"t":36,"s":[0]},{"i":{"x":[0.999],"y":[1]},"o":{"x":[0.001],"y":[0]},"t":40,"s":[100]},{"i":{"x":[0.999],"y":[1]},"o":{"x":[1],"y":[0]},"t":52,"s":[100]},{"t":60,"s":[0]}],"ix":11},"r":{"a":0,"k":-134.667,"ix":10},"p":{"a":0,"k":[223.861,-298.612,0],"ix":2,"l":2},"a":{"a":0,"k":[500,250,0],"ix":1,"l":2},"s":{"a":0,"k":[76.343,76.343,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.78823530674,0.921568632126,0,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":1000,"h":500,"ip":36,"op":68.8,"st":36,"bm":0},{"ddd":0,"ind":6,"ty":0,"nm":"touch - pink 2","parent":7,"refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-18.004,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.7,"y":0},"t":30,"s":[189.149,-155.08,0],"to":[0,0,0],"ti":[0,0,0]},{"t":43,"s":[302.09,49.626,0]}],"ix":2,"l":2},"a":{"a":0,"k":[750,750,0],"ix":1,"l":2},"s":{"a":0,"k":[27.198,27.198,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.78823530674,0.921568632126,0,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":1500,"h":1500,"ip":20,"op":74,"st":20,"bm":0},{"ddd":0,"ind":7,"ty":3,"nm":"Layer 1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.001],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.999],"y":[1]},"o":{"x":[0.001],"y":[0]},"t":20,"s":[100]},{"i":{"x":[0.999],"y":[1]},"o":{"x":[1],"y":[0]},"t":43,"s":[100]},{"t":73,"s":[0]}],"ix":11},"r":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.001],"y":[0]},"t":0,"s":[-55.581]},{"i":{"x":[0],"y":[1]},"o":{"x":[0.7],"y":[0]},"t":30,"s":[18.004]},{"t":43,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.001,"y":0},"t":0,"s":[960,1714,0],"to":[0,0,0],"ti":[0,0,0]},{"t":20,"s":[960,1434,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-40,434,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0,0,0],"y":[1,1,1]},"o":{"x":[0.001,0.001,0.001],"y":[0,0,0]},"t":20,"s":[116,116,100]},{"i":{"x":[0,0,0],"y":[1,1,1]},"o":{"x":[0.7,0.7,0.7],"y":[0,0,0]},"t":30,"s":[85,85,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.7,0.7,0.7],"y":[0,0,0]},"t":43,"s":[85,85,100]},{"t":73,"s":[100,100,100]}],"ix":6,"l":2}},"ao":0,"ip":0,"op":74,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":0,"nm":"touch - pink 2","parent":7,"refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-18.004,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.7,"y":0},"t":30,"s":[-44.636,-333.932,0],"to":[0,0,0],"ti":[0,0,0]},{"t":43,"s":[-115.225,-329.226,0]}],"ix":2,"l":2},"a":{"a":0,"k":[750,750,0],"ix":1,"l":2},"s":{"a":0,"k":[27.198,27.198,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.78823530674,0.921568632126,0,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":1500,"h":1500,"ip":20,"op":74,"st":20,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/website/images/512px.png b/website/images/512px.png new file mode 100644 index 0000000..dd57fee Binary files /dev/null and b/website/images/512px.png differ diff --git a/website/images/image.svg b/website/images/image.svg new file mode 100644 index 0000000..b8832e4 --- /dev/null +++ b/website/images/image.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/website/images/logo-modern.svg b/website/images/logo-modern.svg new file mode 100644 index 0000000..f3243b3 --- /dev/null +++ b/website/images/logo-modern.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/website/index-neocities.html b/website/index-neocities.html deleted file mode 100644 index 3f54bd6..0000000 --- a/website/index-neocities.html +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - user@litruv.neocities.org - Terminal - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-

-    
-
-
-
-
- LITRUV.NEOCITIES.ORG TERMINAL - - Docs - GitHub - Bluesky - -
-
- -
-
-
-
- Ready - - - - - - - - -
-
- - - - - - - - diff --git a/website/index.html b/website/index.html index 410098d..2640e2e 100644 --- a/website/index.html +++ b/website/index.html @@ -3,8 +3,8 @@ - Lit.ruv.wtf Terminal - + Lit.ruv.wtf - Interactive Node Graph + @@ -18,59 +18,313 @@ - - + + - - + + - - + + -
-
-

-    
-
-
-
-
- LIT.RUV.WTF TERMINAL - - Docs - GitHub - Bluesky - -
-
- -
+
+ + + +
+
-
-
- Ready - - - - - - - - + +
+ + +
+ +
- - - - - - + + + + + + + + + + + + + + + diff --git a/website/items/apple.png b/website/items/apple.png deleted file mode 100644 index 3e2ee59..0000000 Binary files a/website/items/apple.png and /dev/null differ diff --git a/website/items/apple_golden.png b/website/items/apple_golden.png deleted file mode 100644 index 6201989..0000000 Binary files a/website/items/apple_golden.png and /dev/null differ diff --git a/website/items/arrow.png b/website/items/arrow.png deleted file mode 100644 index 4f748a0..0000000 Binary files a/website/items/arrow.png and /dev/null differ diff --git a/website/items/banner_base.png b/website/items/banner_base.png deleted file mode 100644 index a074f2a..0000000 Binary files a/website/items/banner_base.png and /dev/null differ diff --git a/website/items/banner_overlay.png b/website/items/banner_overlay.png deleted file mode 100644 index 560108f..0000000 Binary files a/website/items/banner_overlay.png and /dev/null differ diff --git a/website/items/barrier.png b/website/items/barrier.png deleted file mode 100644 index 7ccfa7b..0000000 Binary files a/website/items/barrier.png and /dev/null differ diff --git a/website/items/bed.png b/website/items/bed.png deleted file mode 100644 index 22a4cf0..0000000 Binary files a/website/items/bed.png and /dev/null differ diff --git a/website/items/beef_cooked.png b/website/items/beef_cooked.png deleted file mode 100644 index 7bf0fb9..0000000 Binary files a/website/items/beef_cooked.png and /dev/null differ diff --git a/website/items/beef_raw.png b/website/items/beef_raw.png deleted file mode 100644 index 82af4e7..0000000 Binary files a/website/items/beef_raw.png and /dev/null differ diff --git a/website/items/blaze_powder.png b/website/items/blaze_powder.png deleted file mode 100644 index 89c57d6..0000000 Binary files a/website/items/blaze_powder.png and /dev/null differ diff --git a/website/items/blaze_rod.png b/website/items/blaze_rod.png deleted file mode 100644 index 7050e10..0000000 Binary files a/website/items/blaze_rod.png and /dev/null differ diff --git a/website/items/boat.png b/website/items/boat.png deleted file mode 100644 index 235e0a2..0000000 Binary files a/website/items/boat.png and /dev/null differ diff --git a/website/items/bone.png b/website/items/bone.png deleted file mode 100644 index 3faca89..0000000 Binary files a/website/items/bone.png and /dev/null differ diff --git a/website/items/book_enchanted.png b/website/items/book_enchanted.png deleted file mode 100644 index 8742bee..0000000 Binary files a/website/items/book_enchanted.png and /dev/null differ diff --git a/website/items/book_normal.png b/website/items/book_normal.png deleted file mode 100644 index f791ae4..0000000 Binary files a/website/items/book_normal.png and /dev/null differ diff --git a/website/items/book_writable.png b/website/items/book_writable.png deleted file mode 100644 index fa95246..0000000 Binary files a/website/items/book_writable.png and /dev/null differ diff --git a/website/items/book_written.png b/website/items/book_written.png deleted file mode 100644 index b2a2aa8..0000000 Binary files a/website/items/book_written.png and /dev/null differ diff --git a/website/items/bow_pulling_0.png b/website/items/bow_pulling_0.png deleted file mode 100644 index 2022aab..0000000 Binary files a/website/items/bow_pulling_0.png and /dev/null differ diff --git a/website/items/bow_pulling_1.png b/website/items/bow_pulling_1.png deleted file mode 100644 index a132079..0000000 Binary files a/website/items/bow_pulling_1.png and /dev/null differ diff --git a/website/items/bow_pulling_2.png b/website/items/bow_pulling_2.png deleted file mode 100644 index d459e68..0000000 Binary files a/website/items/bow_pulling_2.png and /dev/null differ diff --git a/website/items/bow_standby.png b/website/items/bow_standby.png deleted file mode 100644 index d709b9c..0000000 Binary files a/website/items/bow_standby.png and /dev/null differ diff --git a/website/items/bowl.png b/website/items/bowl.png deleted file mode 100644 index 63d16ad..0000000 Binary files a/website/items/bowl.png and /dev/null differ diff --git a/website/items/bread.png b/website/items/bread.png deleted file mode 100644 index c833604..0000000 Binary files a/website/items/bread.png and /dev/null differ diff --git a/website/items/brewing_stand.png b/website/items/brewing_stand.png deleted file mode 100644 index 97db9af..0000000 Binary files a/website/items/brewing_stand.png and /dev/null differ diff --git a/website/items/brick.png b/website/items/brick.png deleted file mode 100644 index 11e74be..0000000 Binary files a/website/items/brick.png and /dev/null differ diff --git a/website/items/bucket_empty.png b/website/items/bucket_empty.png deleted file mode 100644 index f8e5369..0000000 Binary files a/website/items/bucket_empty.png and /dev/null differ diff --git a/website/items/bucket_lava.png b/website/items/bucket_lava.png deleted file mode 100644 index 13a1957..0000000 Binary files a/website/items/bucket_lava.png and /dev/null differ diff --git a/website/items/bucket_milk.png b/website/items/bucket_milk.png deleted file mode 100644 index c77d92c..0000000 Binary files a/website/items/bucket_milk.png and /dev/null differ diff --git a/website/items/bucket_water.png b/website/items/bucket_water.png deleted file mode 100644 index 2f36acc..0000000 Binary files a/website/items/bucket_water.png and /dev/null differ diff --git a/website/items/cake.png b/website/items/cake.png deleted file mode 100644 index 46e94b8..0000000 Binary files a/website/items/cake.png and /dev/null differ diff --git a/website/items/carrot.png b/website/items/carrot.png deleted file mode 100644 index 2d08aab..0000000 Binary files a/website/items/carrot.png and /dev/null differ diff --git a/website/items/carrot_golden.png b/website/items/carrot_golden.png deleted file mode 100644 index e0f1ea6..0000000 Binary files a/website/items/carrot_golden.png and /dev/null differ diff --git a/website/items/carrot_on_a_stick.png b/website/items/carrot_on_a_stick.png deleted file mode 100644 index 9e88571..0000000 Binary files a/website/items/carrot_on_a_stick.png and /dev/null differ diff --git a/website/items/cauldron.png b/website/items/cauldron.png deleted file mode 100644 index e3186eb..0000000 Binary files a/website/items/cauldron.png and /dev/null differ diff --git a/website/items/chainmail_boots.png b/website/items/chainmail_boots.png deleted file mode 100644 index 54a4a15..0000000 Binary files a/website/items/chainmail_boots.png and /dev/null differ diff --git a/website/items/chainmail_chestplate.png b/website/items/chainmail_chestplate.png deleted file mode 100644 index cd2115a..0000000 Binary files a/website/items/chainmail_chestplate.png and /dev/null differ diff --git a/website/items/chainmail_helmet.png b/website/items/chainmail_helmet.png deleted file mode 100644 index a143338..0000000 Binary files a/website/items/chainmail_helmet.png and /dev/null differ diff --git a/website/items/chainmail_leggings.png b/website/items/chainmail_leggings.png deleted file mode 100644 index dae4b26..0000000 Binary files a/website/items/chainmail_leggings.png and /dev/null differ diff --git a/website/items/charcoal.png b/website/items/charcoal.png deleted file mode 100644 index 20d5b25..0000000 Binary files a/website/items/charcoal.png and /dev/null differ diff --git a/website/items/chicken_cooked.png b/website/items/chicken_cooked.png deleted file mode 100644 index 890b5b3..0000000 Binary files a/website/items/chicken_cooked.png and /dev/null differ diff --git a/website/items/chicken_raw.png b/website/items/chicken_raw.png deleted file mode 100644 index 6d25922..0000000 Binary files a/website/items/chicken_raw.png and /dev/null differ diff --git a/website/items/clay_ball.png b/website/items/clay_ball.png deleted file mode 100644 index 5103d6c..0000000 Binary files a/website/items/clay_ball.png and /dev/null differ diff --git a/website/items/clock.png b/website/items/clock.png deleted file mode 100644 index 069a0ab..0000000 Binary files a/website/items/clock.png and /dev/null differ diff --git a/website/items/clock.png.mcmeta b/website/items/clock.png.mcmeta deleted file mode 100644 index 4f0718a..0000000 --- a/website/items/clock.png.mcmeta +++ /dev/null @@ -1,3 +0,0 @@ -{ - "animation": {} -} \ No newline at end of file diff --git a/website/items/coal.png b/website/items/coal.png deleted file mode 100644 index 5563b6f..0000000 Binary files a/website/items/coal.png and /dev/null differ diff --git a/website/items/comparator.png b/website/items/comparator.png deleted file mode 100644 index 28b8eec..0000000 Binary files a/website/items/comparator.png and /dev/null differ diff --git a/website/items/compass.png b/website/items/compass.png deleted file mode 100644 index 9dcbdfe..0000000 Binary files a/website/items/compass.png and /dev/null differ diff --git a/website/items/compass.png.mcmeta b/website/items/compass.png.mcmeta deleted file mode 100644 index 4f0718a..0000000 --- a/website/items/compass.png.mcmeta +++ /dev/null @@ -1,3 +0,0 @@ -{ - "animation": {} -} \ No newline at end of file diff --git a/website/items/cookie.png b/website/items/cookie.png deleted file mode 100644 index 01fe56b..0000000 Binary files a/website/items/cookie.png and /dev/null differ diff --git a/website/items/diamond.png b/website/items/diamond.png deleted file mode 100644 index 10e70a0..0000000 Binary files a/website/items/diamond.png and /dev/null differ diff --git a/website/items/diamond_axe.png b/website/items/diamond_axe.png deleted file mode 100644 index 7627f87..0000000 Binary files a/website/items/diamond_axe.png and /dev/null differ diff --git a/website/items/diamond_boots.png b/website/items/diamond_boots.png deleted file mode 100644 index 70d9e5f..0000000 Binary files a/website/items/diamond_boots.png and /dev/null differ diff --git a/website/items/diamond_chestplate.png b/website/items/diamond_chestplate.png deleted file mode 100644 index afdff93..0000000 Binary files a/website/items/diamond_chestplate.png and /dev/null differ diff --git a/website/items/diamond_helmet.png b/website/items/diamond_helmet.png deleted file mode 100644 index 1c88592..0000000 Binary files a/website/items/diamond_helmet.png and /dev/null differ diff --git a/website/items/diamond_hoe.png b/website/items/diamond_hoe.png deleted file mode 100644 index c9a97e0..0000000 Binary files a/website/items/diamond_hoe.png and /dev/null differ diff --git a/website/items/diamond_horse_armor.png b/website/items/diamond_horse_armor.png deleted file mode 100644 index b281aad..0000000 Binary files a/website/items/diamond_horse_armor.png and /dev/null differ diff --git a/website/items/diamond_leggings.png b/website/items/diamond_leggings.png deleted file mode 100644 index a99c896..0000000 Binary files a/website/items/diamond_leggings.png and /dev/null differ diff --git a/website/items/diamond_pickaxe.png b/website/items/diamond_pickaxe.png deleted file mode 100644 index c279828..0000000 Binary files a/website/items/diamond_pickaxe.png and /dev/null differ diff --git a/website/items/diamond_shovel.png b/website/items/diamond_shovel.png deleted file mode 100644 index e4a8992..0000000 Binary files a/website/items/diamond_shovel.png and /dev/null differ diff --git a/website/items/diamond_sword.png b/website/items/diamond_sword.png deleted file mode 100644 index 2a1552d..0000000 Binary files a/website/items/diamond_sword.png and /dev/null differ diff --git a/website/items/door_acacia.png b/website/items/door_acacia.png deleted file mode 100644 index c903484..0000000 Binary files a/website/items/door_acacia.png and /dev/null differ diff --git a/website/items/door_birch.png b/website/items/door_birch.png deleted file mode 100644 index 7c88c41..0000000 Binary files a/website/items/door_birch.png and /dev/null differ diff --git a/website/items/door_dark_oak.png b/website/items/door_dark_oak.png deleted file mode 100644 index 7862d9a..0000000 Binary files a/website/items/door_dark_oak.png and /dev/null differ diff --git a/website/items/door_iron.png b/website/items/door_iron.png deleted file mode 100644 index b6715ed..0000000 Binary files a/website/items/door_iron.png and /dev/null differ diff --git a/website/items/door_jungle.png b/website/items/door_jungle.png deleted file mode 100644 index cb3753d..0000000 Binary files a/website/items/door_jungle.png and /dev/null differ diff --git a/website/items/door_spruce.png b/website/items/door_spruce.png deleted file mode 100644 index 4eddb7e..0000000 Binary files a/website/items/door_spruce.png and /dev/null differ diff --git a/website/items/door_wood.png b/website/items/door_wood.png deleted file mode 100644 index 20b9be6..0000000 Binary files a/website/items/door_wood.png and /dev/null differ diff --git a/website/items/dye_powder_black.png b/website/items/dye_powder_black.png deleted file mode 100644 index 909bea5..0000000 Binary files a/website/items/dye_powder_black.png and /dev/null differ diff --git a/website/items/dye_powder_blue.png b/website/items/dye_powder_blue.png deleted file mode 100644 index 4713983..0000000 Binary files a/website/items/dye_powder_blue.png and /dev/null differ diff --git a/website/items/dye_powder_brown.png b/website/items/dye_powder_brown.png deleted file mode 100644 index 62eae8d..0000000 Binary files a/website/items/dye_powder_brown.png and /dev/null differ diff --git a/website/items/dye_powder_cyan.png b/website/items/dye_powder_cyan.png deleted file mode 100644 index 7b10c11..0000000 Binary files a/website/items/dye_powder_cyan.png and /dev/null differ diff --git a/website/items/dye_powder_gray.png b/website/items/dye_powder_gray.png deleted file mode 100644 index 4fce7e7..0000000 Binary files a/website/items/dye_powder_gray.png and /dev/null differ diff --git a/website/items/dye_powder_green.png b/website/items/dye_powder_green.png deleted file mode 100644 index 009e058..0000000 Binary files a/website/items/dye_powder_green.png and /dev/null differ diff --git a/website/items/dye_powder_light_blue.png b/website/items/dye_powder_light_blue.png deleted file mode 100644 index a8b45c9..0000000 Binary files a/website/items/dye_powder_light_blue.png and /dev/null differ diff --git a/website/items/dye_powder_lime.png b/website/items/dye_powder_lime.png deleted file mode 100644 index 7a78764..0000000 Binary files a/website/items/dye_powder_lime.png and /dev/null differ diff --git a/website/items/dye_powder_magenta.png b/website/items/dye_powder_magenta.png deleted file mode 100644 index ea7342a..0000000 Binary files a/website/items/dye_powder_magenta.png and /dev/null differ diff --git a/website/items/dye_powder_orange.png b/website/items/dye_powder_orange.png deleted file mode 100644 index 677a1aa..0000000 Binary files a/website/items/dye_powder_orange.png and /dev/null differ diff --git a/website/items/dye_powder_pink.png b/website/items/dye_powder_pink.png deleted file mode 100644 index 1acc483..0000000 Binary files a/website/items/dye_powder_pink.png and /dev/null differ diff --git a/website/items/dye_powder_purple.png b/website/items/dye_powder_purple.png deleted file mode 100644 index 7d201c2..0000000 Binary files a/website/items/dye_powder_purple.png and /dev/null differ diff --git a/website/items/dye_powder_red.png b/website/items/dye_powder_red.png deleted file mode 100644 index 2d1a742..0000000 Binary files a/website/items/dye_powder_red.png and /dev/null differ diff --git a/website/items/dye_powder_silver.png b/website/items/dye_powder_silver.png deleted file mode 100644 index fa9e501..0000000 Binary files a/website/items/dye_powder_silver.png and /dev/null differ diff --git a/website/items/dye_powder_white.png b/website/items/dye_powder_white.png deleted file mode 100644 index 5b1833b..0000000 Binary files a/website/items/dye_powder_white.png and /dev/null differ diff --git a/website/items/dye_powder_yellow.png b/website/items/dye_powder_yellow.png deleted file mode 100644 index 95e0673..0000000 Binary files a/website/items/dye_powder_yellow.png and /dev/null differ diff --git a/website/items/egg.png b/website/items/egg.png deleted file mode 100644 index a6fe2bf..0000000 Binary files a/website/items/egg.png and /dev/null differ diff --git a/website/items/emerald.png b/website/items/emerald.png deleted file mode 100644 index 98d953e..0000000 Binary files a/website/items/emerald.png and /dev/null differ diff --git a/website/items/empty_armor_slot_boots.png b/website/items/empty_armor_slot_boots.png deleted file mode 100644 index fd7e05f..0000000 Binary files a/website/items/empty_armor_slot_boots.png and /dev/null differ diff --git a/website/items/empty_armor_slot_chestplate.png b/website/items/empty_armor_slot_chestplate.png deleted file mode 100644 index 6e632b9..0000000 Binary files a/website/items/empty_armor_slot_chestplate.png and /dev/null differ diff --git a/website/items/empty_armor_slot_helmet.png b/website/items/empty_armor_slot_helmet.png deleted file mode 100644 index 3a455f3..0000000 Binary files a/website/items/empty_armor_slot_helmet.png and /dev/null differ diff --git a/website/items/empty_armor_slot_leggings.png b/website/items/empty_armor_slot_leggings.png deleted file mode 100644 index 28b2c49..0000000 Binary files a/website/items/empty_armor_slot_leggings.png and /dev/null differ diff --git a/website/items/ender_eye.png b/website/items/ender_eye.png deleted file mode 100644 index 8c4ef4e..0000000 Binary files a/website/items/ender_eye.png and /dev/null differ diff --git a/website/items/ender_pearl.png b/website/items/ender_pearl.png deleted file mode 100644 index 4b752a6..0000000 Binary files a/website/items/ender_pearl.png and /dev/null differ diff --git a/website/items/experience_bottle.png b/website/items/experience_bottle.png deleted file mode 100644 index ae4214f..0000000 Binary files a/website/items/experience_bottle.png and /dev/null differ diff --git a/website/items/feather.png b/website/items/feather.png deleted file mode 100644 index d4c3be5..0000000 Binary files a/website/items/feather.png and /dev/null differ diff --git a/website/items/fireball.png b/website/items/fireball.png deleted file mode 100644 index d62a6f4..0000000 Binary files a/website/items/fireball.png and /dev/null differ diff --git a/website/items/fireworks.png b/website/items/fireworks.png deleted file mode 100644 index f1e07fd..0000000 Binary files a/website/items/fireworks.png and /dev/null differ diff --git a/website/items/fireworks_charge.png b/website/items/fireworks_charge.png deleted file mode 100644 index 5c89362..0000000 Binary files a/website/items/fireworks_charge.png and /dev/null differ diff --git a/website/items/fireworks_charge_overlay.png b/website/items/fireworks_charge_overlay.png deleted file mode 100644 index d8b91a9..0000000 Binary files a/website/items/fireworks_charge_overlay.png and /dev/null differ diff --git a/website/items/fish_clownfish_raw.png b/website/items/fish_clownfish_raw.png deleted file mode 100644 index 41df72f..0000000 Binary files a/website/items/fish_clownfish_raw.png and /dev/null differ diff --git a/website/items/fish_cod_cooked.png b/website/items/fish_cod_cooked.png deleted file mode 100644 index 87564c7..0000000 Binary files a/website/items/fish_cod_cooked.png and /dev/null differ diff --git a/website/items/fish_cod_raw.png b/website/items/fish_cod_raw.png deleted file mode 100644 index 32996bf..0000000 Binary files a/website/items/fish_cod_raw.png and /dev/null differ diff --git a/website/items/fish_pufferfish_raw.png b/website/items/fish_pufferfish_raw.png deleted file mode 100644 index aadbd88..0000000 Binary files a/website/items/fish_pufferfish_raw.png and /dev/null differ diff --git a/website/items/fish_salmon_cooked.png b/website/items/fish_salmon_cooked.png deleted file mode 100644 index fb765ed..0000000 Binary files a/website/items/fish_salmon_cooked.png and /dev/null differ diff --git a/website/items/fish_salmon_raw.png b/website/items/fish_salmon_raw.png deleted file mode 100644 index 68bcd69..0000000 Binary files a/website/items/fish_salmon_raw.png and /dev/null differ diff --git a/website/items/fishing_rod_cast.png b/website/items/fishing_rod_cast.png deleted file mode 100644 index a5ab378..0000000 Binary files a/website/items/fishing_rod_cast.png and /dev/null differ diff --git a/website/items/fishing_rod_uncast.png b/website/items/fishing_rod_uncast.png deleted file mode 100644 index d4b53f0..0000000 Binary files a/website/items/fishing_rod_uncast.png and /dev/null differ diff --git a/website/items/flint.png b/website/items/flint.png deleted file mode 100644 index 5f51093..0000000 Binary files a/website/items/flint.png and /dev/null differ diff --git a/website/items/flint_and_steel.png b/website/items/flint_and_steel.png deleted file mode 100644 index 77bc340..0000000 Binary files a/website/items/flint_and_steel.png and /dev/null differ diff --git a/website/items/flower_pot.png b/website/items/flower_pot.png deleted file mode 100644 index c4f26d2..0000000 Binary files a/website/items/flower_pot.png and /dev/null differ diff --git a/website/items/ghast_tear.png b/website/items/ghast_tear.png deleted file mode 100644 index e5c741f..0000000 Binary files a/website/items/ghast_tear.png and /dev/null differ diff --git a/website/items/glowstone_dust.png b/website/items/glowstone_dust.png deleted file mode 100644 index edd93a6..0000000 Binary files a/website/items/glowstone_dust.png and /dev/null differ diff --git a/website/items/gold_axe.png b/website/items/gold_axe.png deleted file mode 100644 index 0f47b60..0000000 Binary files a/website/items/gold_axe.png and /dev/null differ diff --git a/website/items/gold_boots.png b/website/items/gold_boots.png deleted file mode 100644 index f6033d2..0000000 Binary files a/website/items/gold_boots.png and /dev/null differ diff --git a/website/items/gold_chestplate.png b/website/items/gold_chestplate.png deleted file mode 100644 index e36076a..0000000 Binary files a/website/items/gold_chestplate.png and /dev/null differ diff --git a/website/items/gold_helmet.png b/website/items/gold_helmet.png deleted file mode 100644 index 9eb89a0..0000000 Binary files a/website/items/gold_helmet.png and /dev/null differ diff --git a/website/items/gold_hoe.png b/website/items/gold_hoe.png deleted file mode 100644 index 1685d47..0000000 Binary files a/website/items/gold_hoe.png and /dev/null differ diff --git a/website/items/gold_horse_armor.png b/website/items/gold_horse_armor.png deleted file mode 100644 index 7c5c3a5..0000000 Binary files a/website/items/gold_horse_armor.png and /dev/null differ diff --git a/website/items/gold_ingot.png b/website/items/gold_ingot.png deleted file mode 100644 index ea781e7..0000000 Binary files a/website/items/gold_ingot.png and /dev/null differ diff --git a/website/items/gold_leggings.png b/website/items/gold_leggings.png deleted file mode 100644 index da23771..0000000 Binary files a/website/items/gold_leggings.png and /dev/null differ diff --git a/website/items/gold_nugget.png b/website/items/gold_nugget.png deleted file mode 100644 index 3a9a2fe..0000000 Binary files a/website/items/gold_nugget.png and /dev/null differ diff --git a/website/items/gold_pickaxe.png b/website/items/gold_pickaxe.png deleted file mode 100644 index ecccafe..0000000 Binary files a/website/items/gold_pickaxe.png and /dev/null differ diff --git a/website/items/gold_shovel.png b/website/items/gold_shovel.png deleted file mode 100644 index 150cbb9..0000000 Binary files a/website/items/gold_shovel.png and /dev/null differ diff --git a/website/items/gold_sword.png b/website/items/gold_sword.png deleted file mode 100644 index 0ddef04..0000000 Binary files a/website/items/gold_sword.png and /dev/null differ diff --git a/website/items/gunpowder.png b/website/items/gunpowder.png deleted file mode 100644 index 73cadec..0000000 Binary files a/website/items/gunpowder.png and /dev/null differ diff --git a/website/items/hopper.png b/website/items/hopper.png deleted file mode 100644 index f8b244f..0000000 Binary files a/website/items/hopper.png and /dev/null differ diff --git a/website/items/iron_axe.png b/website/items/iron_axe.png deleted file mode 100644 index 8bf133e..0000000 Binary files a/website/items/iron_axe.png and /dev/null differ diff --git a/website/items/iron_boots.png b/website/items/iron_boots.png deleted file mode 100644 index b69ca05..0000000 Binary files a/website/items/iron_boots.png and /dev/null differ diff --git a/website/items/iron_chestplate.png b/website/items/iron_chestplate.png deleted file mode 100644 index e7993ce..0000000 Binary files a/website/items/iron_chestplate.png and /dev/null differ diff --git a/website/items/iron_helmet.png b/website/items/iron_helmet.png deleted file mode 100644 index 65e64cc..0000000 Binary files a/website/items/iron_helmet.png and /dev/null differ diff --git a/website/items/iron_hoe.png b/website/items/iron_hoe.png deleted file mode 100644 index 28d4c36..0000000 Binary files a/website/items/iron_hoe.png and /dev/null differ diff --git a/website/items/iron_horse_armor.png b/website/items/iron_horse_armor.png deleted file mode 100644 index 5d697d1..0000000 Binary files a/website/items/iron_horse_armor.png and /dev/null differ diff --git a/website/items/iron_ingot.png b/website/items/iron_ingot.png deleted file mode 100644 index 3833fa0..0000000 Binary files a/website/items/iron_ingot.png and /dev/null differ diff --git a/website/items/iron_leggings.png b/website/items/iron_leggings.png deleted file mode 100644 index ad53673..0000000 Binary files a/website/items/iron_leggings.png and /dev/null differ diff --git a/website/items/iron_pickaxe.png b/website/items/iron_pickaxe.png deleted file mode 100644 index d21440b..0000000 Binary files a/website/items/iron_pickaxe.png and /dev/null differ diff --git a/website/items/iron_shovel.png b/website/items/iron_shovel.png deleted file mode 100644 index 079b236..0000000 Binary files a/website/items/iron_shovel.png and /dev/null differ diff --git a/website/items/iron_sword.png b/website/items/iron_sword.png deleted file mode 100644 index 4d49c5a..0000000 Binary files a/website/items/iron_sword.png and /dev/null differ diff --git a/website/items/item_frame.png b/website/items/item_frame.png deleted file mode 100644 index 261c98a..0000000 Binary files a/website/items/item_frame.png and /dev/null differ diff --git a/website/items/lead.png b/website/items/lead.png deleted file mode 100644 index 0ef5312..0000000 Binary files a/website/items/lead.png and /dev/null differ diff --git a/website/items/leather.png b/website/items/leather.png deleted file mode 100644 index 13dc199..0000000 Binary files a/website/items/leather.png and /dev/null differ diff --git a/website/items/leather_boots.png b/website/items/leather_boots.png deleted file mode 100644 index 61d7198..0000000 Binary files a/website/items/leather_boots.png and /dev/null differ diff --git a/website/items/leather_boots_overlay.png b/website/items/leather_boots_overlay.png deleted file mode 100644 index b6896d8..0000000 Binary files a/website/items/leather_boots_overlay.png and /dev/null differ diff --git a/website/items/leather_chestplate.png b/website/items/leather_chestplate.png deleted file mode 100644 index e534aef..0000000 Binary files a/website/items/leather_chestplate.png and /dev/null differ diff --git a/website/items/leather_chestplate_overlay.png b/website/items/leather_chestplate_overlay.png deleted file mode 100644 index 125fd34..0000000 Binary files a/website/items/leather_chestplate_overlay.png and /dev/null differ diff --git a/website/items/leather_helmet.png b/website/items/leather_helmet.png deleted file mode 100644 index 6f64763..0000000 Binary files a/website/items/leather_helmet.png and /dev/null differ diff --git a/website/items/leather_helmet_overlay.png b/website/items/leather_helmet_overlay.png deleted file mode 100644 index 8040d77..0000000 Binary files a/website/items/leather_helmet_overlay.png and /dev/null differ diff --git a/website/items/leather_leggings.png b/website/items/leather_leggings.png deleted file mode 100644 index c2d3f03..0000000 Binary files a/website/items/leather_leggings.png and /dev/null differ diff --git a/website/items/leather_leggings_overlay.png b/website/items/leather_leggings_overlay.png deleted file mode 100644 index 813ba26..0000000 Binary files a/website/items/leather_leggings_overlay.png and /dev/null differ diff --git a/website/items/magma_cream.png b/website/items/magma_cream.png deleted file mode 100644 index b2be210..0000000 Binary files a/website/items/magma_cream.png and /dev/null differ diff --git a/website/items/map_empty.png b/website/items/map_empty.png deleted file mode 100644 index 8dc6e58..0000000 Binary files a/website/items/map_empty.png and /dev/null differ diff --git a/website/items/map_filled.png b/website/items/map_filled.png deleted file mode 100644 index 1381e21..0000000 Binary files a/website/items/map_filled.png and /dev/null differ diff --git a/website/items/melon.png b/website/items/melon.png deleted file mode 100644 index 590f47a..0000000 Binary files a/website/items/melon.png and /dev/null differ diff --git a/website/items/melon_speckled.png b/website/items/melon_speckled.png deleted file mode 100644 index dee1bf5..0000000 Binary files a/website/items/melon_speckled.png and /dev/null differ diff --git a/website/items/minecart_chest.png b/website/items/minecart_chest.png deleted file mode 100644 index 78a3778..0000000 Binary files a/website/items/minecart_chest.png and /dev/null differ diff --git a/website/items/minecart_command_block.png b/website/items/minecart_command_block.png deleted file mode 100644 index c597ee7..0000000 Binary files a/website/items/minecart_command_block.png and /dev/null differ diff --git a/website/items/minecart_furnace.png b/website/items/minecart_furnace.png deleted file mode 100644 index 5478f25..0000000 Binary files a/website/items/minecart_furnace.png and /dev/null differ diff --git a/website/items/minecart_hopper.png b/website/items/minecart_hopper.png deleted file mode 100644 index 8a138fb..0000000 Binary files a/website/items/minecart_hopper.png and /dev/null differ diff --git a/website/items/minecart_normal.png b/website/items/minecart_normal.png deleted file mode 100644 index 2046f2b..0000000 Binary files a/website/items/minecart_normal.png and /dev/null differ diff --git a/website/items/minecart_tnt.png b/website/items/minecart_tnt.png deleted file mode 100644 index 561279b..0000000 Binary files a/website/items/minecart_tnt.png and /dev/null differ diff --git a/website/items/mushroom_stew.png b/website/items/mushroom_stew.png deleted file mode 100644 index 5598bc7..0000000 Binary files a/website/items/mushroom_stew.png and /dev/null differ diff --git a/website/items/mutton_cooked.png b/website/items/mutton_cooked.png deleted file mode 100644 index e1b62dc..0000000 Binary files a/website/items/mutton_cooked.png and /dev/null differ diff --git a/website/items/mutton_raw.png b/website/items/mutton_raw.png deleted file mode 100644 index 1222ff4..0000000 Binary files a/website/items/mutton_raw.png and /dev/null differ diff --git a/website/items/name_tag.png b/website/items/name_tag.png deleted file mode 100644 index a88f559..0000000 Binary files a/website/items/name_tag.png and /dev/null differ diff --git a/website/items/nether_star.png b/website/items/nether_star.png deleted file mode 100644 index 6b848af..0000000 Binary files a/website/items/nether_star.png and /dev/null differ diff --git a/website/items/nether_wart.png b/website/items/nether_wart.png deleted file mode 100644 index 09da1e3..0000000 Binary files a/website/items/nether_wart.png and /dev/null differ diff --git a/website/items/netherbrick.png b/website/items/netherbrick.png deleted file mode 100644 index e9c14ed..0000000 Binary files a/website/items/netherbrick.png and /dev/null differ diff --git a/website/items/painting.png b/website/items/painting.png deleted file mode 100644 index b394f30..0000000 Binary files a/website/items/painting.png and /dev/null differ diff --git a/website/items/paper.png b/website/items/paper.png deleted file mode 100644 index a1d9c7e..0000000 Binary files a/website/items/paper.png and /dev/null differ diff --git a/website/items/porkchop_cooked.png b/website/items/porkchop_cooked.png deleted file mode 100644 index 605d3f5..0000000 Binary files a/website/items/porkchop_cooked.png and /dev/null differ diff --git a/website/items/porkchop_raw.png b/website/items/porkchop_raw.png deleted file mode 100644 index 7e83c1a..0000000 Binary files a/website/items/porkchop_raw.png and /dev/null differ diff --git a/website/items/potato.png b/website/items/potato.png deleted file mode 100644 index c1d8541..0000000 Binary files a/website/items/potato.png and /dev/null differ diff --git a/website/items/potato_baked.png b/website/items/potato_baked.png deleted file mode 100644 index e4d765b..0000000 Binary files a/website/items/potato_baked.png and /dev/null differ diff --git a/website/items/potato_poisonous.png b/website/items/potato_poisonous.png deleted file mode 100644 index 6f154af..0000000 Binary files a/website/items/potato_poisonous.png and /dev/null differ diff --git a/website/items/potion_bottle_drinkable.png b/website/items/potion_bottle_drinkable.png deleted file mode 100644 index 87339d7..0000000 Binary files a/website/items/potion_bottle_drinkable.png and /dev/null differ diff --git a/website/items/potion_bottle_empty.png b/website/items/potion_bottle_empty.png deleted file mode 100644 index 87339d7..0000000 Binary files a/website/items/potion_bottle_empty.png and /dev/null differ diff --git a/website/items/potion_bottle_splash.png b/website/items/potion_bottle_splash.png deleted file mode 100644 index 03b1f90..0000000 Binary files a/website/items/potion_bottle_splash.png and /dev/null differ diff --git a/website/items/potion_overlay.png b/website/items/potion_overlay.png deleted file mode 100644 index 61864e9..0000000 Binary files a/website/items/potion_overlay.png and /dev/null differ diff --git a/website/items/prismarine_crystals.png b/website/items/prismarine_crystals.png deleted file mode 100644 index 7cf87d5..0000000 Binary files a/website/items/prismarine_crystals.png and /dev/null differ diff --git a/website/items/prismarine_shard.png b/website/items/prismarine_shard.png deleted file mode 100644 index 4030b4c..0000000 Binary files a/website/items/prismarine_shard.png and /dev/null differ diff --git a/website/items/pumpkin_pie.png b/website/items/pumpkin_pie.png deleted file mode 100644 index c21a032..0000000 Binary files a/website/items/pumpkin_pie.png and /dev/null differ diff --git a/website/items/quartz.png b/website/items/quartz.png deleted file mode 100644 index e403446..0000000 Binary files a/website/items/quartz.png and /dev/null differ diff --git a/website/items/quiver.png b/website/items/quiver.png deleted file mode 100644 index 818f728..0000000 Binary files a/website/items/quiver.png and /dev/null differ diff --git a/website/items/rabbit_cooked.png b/website/items/rabbit_cooked.png deleted file mode 100644 index 49657c3..0000000 Binary files a/website/items/rabbit_cooked.png and /dev/null differ diff --git a/website/items/rabbit_foot.png b/website/items/rabbit_foot.png deleted file mode 100644 index f7d9140..0000000 Binary files a/website/items/rabbit_foot.png and /dev/null differ diff --git a/website/items/rabbit_hide.png b/website/items/rabbit_hide.png deleted file mode 100644 index 516b899..0000000 Binary files a/website/items/rabbit_hide.png and /dev/null differ diff --git a/website/items/rabbit_raw.png b/website/items/rabbit_raw.png deleted file mode 100644 index 4b54135..0000000 Binary files a/website/items/rabbit_raw.png and /dev/null differ diff --git a/website/items/rabbit_stew.png b/website/items/rabbit_stew.png deleted file mode 100644 index 93e83e6..0000000 Binary files a/website/items/rabbit_stew.png and /dev/null differ diff --git a/website/items/record_11.png b/website/items/record_11.png deleted file mode 100644 index 07ac487..0000000 Binary files a/website/items/record_11.png and /dev/null differ diff --git a/website/items/record_13.png b/website/items/record_13.png deleted file mode 100644 index ead0c61..0000000 Binary files a/website/items/record_13.png and /dev/null differ diff --git a/website/items/record_blocks.png b/website/items/record_blocks.png deleted file mode 100644 index 3732d6c..0000000 Binary files a/website/items/record_blocks.png and /dev/null differ diff --git a/website/items/record_cat.png b/website/items/record_cat.png deleted file mode 100644 index b3527ef..0000000 Binary files a/website/items/record_cat.png and /dev/null differ diff --git a/website/items/record_chirp.png b/website/items/record_chirp.png deleted file mode 100644 index 1b24e11..0000000 Binary files a/website/items/record_chirp.png and /dev/null differ diff --git a/website/items/record_far.png b/website/items/record_far.png deleted file mode 100644 index 2320efd..0000000 Binary files a/website/items/record_far.png and /dev/null differ diff --git a/website/items/record_mall.png b/website/items/record_mall.png deleted file mode 100644 index 811d046..0000000 Binary files a/website/items/record_mall.png and /dev/null differ diff --git a/website/items/record_mellohi.png b/website/items/record_mellohi.png deleted file mode 100644 index 879b6a4..0000000 Binary files a/website/items/record_mellohi.png and /dev/null differ diff --git a/website/items/record_stal.png b/website/items/record_stal.png deleted file mode 100644 index c650d21..0000000 Binary files a/website/items/record_stal.png and /dev/null differ diff --git a/website/items/record_strad.png b/website/items/record_strad.png deleted file mode 100644 index 72d6e32..0000000 Binary files a/website/items/record_strad.png and /dev/null differ diff --git a/website/items/record_wait.png b/website/items/record_wait.png deleted file mode 100644 index 5306b51..0000000 Binary files a/website/items/record_wait.png and /dev/null differ diff --git a/website/items/record_ward.png b/website/items/record_ward.png deleted file mode 100644 index b33b93d..0000000 Binary files a/website/items/record_ward.png and /dev/null differ diff --git a/website/items/redstone_dust.png b/website/items/redstone_dust.png deleted file mode 100644 index 6da938b..0000000 Binary files a/website/items/redstone_dust.png and /dev/null differ diff --git a/website/items/reeds.png b/website/items/reeds.png deleted file mode 100644 index 40872c6..0000000 Binary files a/website/items/reeds.png and /dev/null differ diff --git a/website/items/repeater.png b/website/items/repeater.png deleted file mode 100644 index 7a07381..0000000 Binary files a/website/items/repeater.png and /dev/null differ diff --git a/website/items/rotten_flesh.png b/website/items/rotten_flesh.png deleted file mode 100644 index 3f39998..0000000 Binary files a/website/items/rotten_flesh.png and /dev/null differ diff --git a/website/items/ruby.png b/website/items/ruby.png deleted file mode 100644 index 4f288d9..0000000 Binary files a/website/items/ruby.png and /dev/null differ diff --git a/website/items/saddle.png b/website/items/saddle.png deleted file mode 100644 index b12eafa..0000000 Binary files a/website/items/saddle.png and /dev/null differ diff --git a/website/items/seeds_melon.png b/website/items/seeds_melon.png deleted file mode 100644 index ef84499..0000000 Binary files a/website/items/seeds_melon.png and /dev/null differ diff --git a/website/items/seeds_pumpkin.png b/website/items/seeds_pumpkin.png deleted file mode 100644 index 8d8f076..0000000 Binary files a/website/items/seeds_pumpkin.png and /dev/null differ diff --git a/website/items/seeds_wheat.png b/website/items/seeds_wheat.png deleted file mode 100644 index 83d23a6..0000000 Binary files a/website/items/seeds_wheat.png and /dev/null differ diff --git a/website/items/shears.png b/website/items/shears.png deleted file mode 100644 index f9c6de1..0000000 Binary files a/website/items/shears.png and /dev/null differ diff --git a/website/items/sign.png b/website/items/sign.png deleted file mode 100644 index 9b6e2ee..0000000 Binary files a/website/items/sign.png and /dev/null differ diff --git a/website/items/slimeball.png b/website/items/slimeball.png deleted file mode 100644 index 46478ee..0000000 Binary files a/website/items/slimeball.png and /dev/null differ diff --git a/website/items/snowball.png b/website/items/snowball.png deleted file mode 100644 index 340c639..0000000 Binary files a/website/items/snowball.png and /dev/null differ diff --git a/website/items/spawn_egg.png b/website/items/spawn_egg.png deleted file mode 100644 index 26cc6b2..0000000 Binary files a/website/items/spawn_egg.png and /dev/null differ diff --git a/website/items/spawn_egg_overlay.png b/website/items/spawn_egg_overlay.png deleted file mode 100644 index 83ec78f..0000000 Binary files a/website/items/spawn_egg_overlay.png and /dev/null differ diff --git a/website/items/spider_eye.png b/website/items/spider_eye.png deleted file mode 100644 index 35d8584..0000000 Binary files a/website/items/spider_eye.png and /dev/null differ diff --git a/website/items/spider_eye_fermented.png b/website/items/spider_eye_fermented.png deleted file mode 100644 index 226ffb8..0000000 Binary files a/website/items/spider_eye_fermented.png and /dev/null differ diff --git a/website/items/stick.png b/website/items/stick.png deleted file mode 100644 index 6f8ce13..0000000 Binary files a/website/items/stick.png and /dev/null differ diff --git a/website/items/stone_axe.png b/website/items/stone_axe.png deleted file mode 100644 index fb33584..0000000 Binary files a/website/items/stone_axe.png and /dev/null differ diff --git a/website/items/stone_hoe.png b/website/items/stone_hoe.png deleted file mode 100644 index d46b272..0000000 Binary files a/website/items/stone_hoe.png and /dev/null differ diff --git a/website/items/stone_pickaxe.png b/website/items/stone_pickaxe.png deleted file mode 100644 index 19a8e50..0000000 Binary files a/website/items/stone_pickaxe.png and /dev/null differ diff --git a/website/items/stone_shovel.png b/website/items/stone_shovel.png deleted file mode 100644 index 8e1c0c2..0000000 Binary files a/website/items/stone_shovel.png and /dev/null differ diff --git a/website/items/stone_sword.png b/website/items/stone_sword.png deleted file mode 100644 index 5810dfd..0000000 Binary files a/website/items/stone_sword.png and /dev/null differ diff --git a/website/items/string.png b/website/items/string.png deleted file mode 100644 index 0b04ddb..0000000 Binary files a/website/items/string.png and /dev/null differ diff --git a/website/items/sugar.png b/website/items/sugar.png deleted file mode 100644 index 0864df9..0000000 Binary files a/website/items/sugar.png and /dev/null differ diff --git a/website/items/wheat.png b/website/items/wheat.png deleted file mode 100644 index bbd2fd9..0000000 Binary files a/website/items/wheat.png and /dev/null differ diff --git a/website/items/wood_axe.png b/website/items/wood_axe.png deleted file mode 100644 index 3a19203..0000000 Binary files a/website/items/wood_axe.png and /dev/null differ diff --git a/website/items/wood_hoe.png b/website/items/wood_hoe.png deleted file mode 100644 index de1c7a6..0000000 Binary files a/website/items/wood_hoe.png and /dev/null differ diff --git a/website/items/wood_pickaxe.png b/website/items/wood_pickaxe.png deleted file mode 100644 index ebc6756..0000000 Binary files a/website/items/wood_pickaxe.png and /dev/null differ diff --git a/website/items/wood_shovel.png b/website/items/wood_shovel.png deleted file mode 100644 index b057911..0000000 Binary files a/website/items/wood_shovel.png and /dev/null differ diff --git a/website/items/wood_sword.png b/website/items/wood_sword.png deleted file mode 100644 index c04dc0a..0000000 Binary files a/website/items/wood_sword.png and /dev/null differ diff --git a/website/items/wooden_armorstand.png b/website/items/wooden_armorstand.png deleted file mode 100644 index 6d27c47..0000000 Binary files a/website/items/wooden_armorstand.png and /dev/null differ diff --git a/website/matrix-bridge.html b/website/matrix-bridge.html deleted file mode 100644 index 904ec3f..0000000 --- a/website/matrix-bridge.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Matrix Chat Bridge - - - - - - - diff --git a/website/matrix-bridge.js b/website/matrix-bridge.js deleted file mode 100644 index cd827ab..0000000 --- a/website/matrix-bridge.js +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Matrix Chat Bridge - Iframe communication layer for CSP-restricted hosts - * Handles Matrix API calls via postMessage from parent window - */ - -// Allow requests from these origins -const ALLOWED_ORIGINS = [ - 'https://litruv.neocities.org', - 'https://lit.ruv.wtf', - 'http://localhost:3000', - 'http://localhost:8080', - 'http://127.0.0.1:3000', - 'http://127.0.0.1:8080' -]; - -/** - * Matrix API configuration - */ -const MATRIX_CONFIG = { - homeserver: 'https://chat.ruv.wtf', - publicReadToken: 'syt_Z2VuZXJhbGNoYXQtcmVhZG9ubHk_sikLltUtfbHlztnanEVm_2icJ1o' -}; - -/** - * Make a Matrix API call - * @param {string} endpoint - API endpoint path - * @param {object} options - Fetch options - * @param {string|null} accessToken - Optional access token - * @returns {Promise} - */ -async function matrixApiFetch(endpoint, options = {}, accessToken = null) { - const url = `${MATRIX_CONFIG.homeserver}${endpoint}`; - const headers = { - 'Content-Type': 'application/json', - ...options.headers - }; - - if (accessToken) { - headers['Authorization'] = `Bearer ${accessToken}`; - } - - try { - const response = await fetch(url, { - ...options, - headers - }); - - const data = await response.json(); - - if (!response.ok) { - return { error: true, ...data }; - } - - return data; - } catch (error) { - return { - error: true, - errcode: 'M_NETWORK_ERROR', - error: error.message - }; - } -} - -/** - * Handle Matrix API requests from parent window - */ -window.addEventListener('message', async (event) => { - // Verify origin - if (!ALLOWED_ORIGINS.includes(event.origin)) { - console.warn('[Matrix Bridge] Rejected message from unauthorized origin:', event.origin); - return; - } - - const { type, requestId, payload } = event.data; - - if (!type || !requestId) { - return; - } - - let result; - - try { - switch (type) { - case 'matrix:api': - result = await handleApiRequest(payload); - break; - - case 'matrix:auth': - result = await handleAuth(payload); - break; - - case 'matrix:sendMessage': - result = await handleSendMessage(payload); - break; - - case 'matrix:fetchPresence': - result = await handleFetchPresence(payload); - break; - - case 'matrix:fetchLastMessage': - result = await handleFetchLastMessage(payload); - break; - - case 'matrix:sync': - result = await handleSync(payload); - break; - - case 'matrix:resolveAlias': - result = await handleResolveAlias(payload); - break; - - default: - result = { - error: true, - errcode: 'M_UNKNOWN_REQUEST', - error: `Unknown request type: ${type}` - }; - } - } catch (error) { - result = { - error: true, - errcode: 'M_BRIDGE_ERROR', - error: error.message - }; - } - - // Send response back to parent - event.source.postMessage({ - type: `${type}:response`, - requestId, - payload: result - }, event.origin); -}); - -/** - * Handle generic Matrix API request - */ -async function handleApiRequest({ endpoint, method = 'GET', body = null, accessToken = null }) { - return await matrixApiFetch( - `/_matrix/client/r0${endpoint}`, - { - method, - body: body ? JSON.stringify(body) : undefined - }, - accessToken - ); -} - -/** - * Handle authentication request - */ -async function handleAuth({ username, password }) { - return await matrixApiFetch('/_matrix/client/r0/login', { - method: 'POST', - body: JSON.stringify({ - type: 'm.login.password', - identifier: { - type: 'm.id.user', - user: username - }, - password: password - }) - }); -} - -/** - * Handle send message request - */ -async function handleSendMessage({ roomId, content, accessToken, txnId }) { - const msgtype = content.msgtype || 'm.text'; - const endpoint = `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`; - - return await matrixApiFetch(endpoint, { - method: 'PUT', - body: JSON.stringify(content) - }, accessToken); -} - -/** - * Handle fetch presence request - */ -async function handleFetchPresence({ userId }) { - return await matrixApiFetch( - `/_matrix/client/r0/presence/${encodeURIComponent(userId)}/status`, - { method: 'GET' }, - MATRIX_CONFIG.publicReadToken - ); -} - -/** - * Handle fetch last message request - */ -async function handleFetchLastMessage({ roomId }) { - return await matrixApiFetch( - `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/messages?dir=b&limit=1`, - { method: 'GET' }, - MATRIX_CONFIG.publicReadToken - ); -} - -/** - * Handle sync request - */ -async function handleSync({ accessToken, since, timeout = 0 }) { - let endpoint = `/_matrix/client/r0/sync?timeout=${timeout}`; - if (since) { - endpoint += `&since=${encodeURIComponent(since)}`; - } - - return await matrixApiFetch(endpoint, { - method: 'GET' - }, accessToken); -} - -/** - * Handle room alias resolution - */ -async function handleResolveAlias({ roomAlias }) { - return await matrixApiFetch( - `/_matrix/client/r0/directory/room/${encodeURIComponent(roomAlias)}`, - { method: 'GET' }, - MATRIX_CONFIG.publicReadToken - ); -} - -// Notify parent that bridge is ready -window.addEventListener('load', () => { - if (window.parent !== window) { - window.parent.postMessage({ - type: 'matrix:bridge:ready' - }, '*'); - } - console.log('[Matrix Bridge] Ready and listening for requests'); -}); diff --git a/website/matrix-client.js b/website/matrix-client.js deleted file mode 100644 index a7b0211..0000000 --- a/website/matrix-client.js +++ /dev/null @@ -1,964 +0,0 @@ -/** - * Matrix Client - Using mxjs-lite library - */ - -import { MxjsClient } from './mxjs-lite.js'; - -/** - * @type {MxjsClient|null} - */ -let mxClient = null; - -/** - * @type {string|null} - */ -let syncToken = null; - -// Dependencies that will be injected -let term, inlineInput, mentionSuggestions, writeClickable, writePrompt, showInlineInput, positionInlineInput, submitInlineInput; - -/** - * Initialize Matrix client with configuration and dependencies - * @param {Object} userConfig - Configuration options - * @param {string} userConfig.homeserver - Matrix homeserver URL - * @param {string} [userConfig.publicReadToken] - Public read token for unauthenticated requests - * @param {Object} deps - UI dependencies - */ -export function initMatrixClient(userConfig, deps) { - // Create new mxjs-lite client - mxClient = new MxjsClient({ - homeserver: userConfig.homeserver || 'https://matrix.org', - publicReadToken: userConfig.publicReadToken || null - }); - - // Inject UI dependencies - if (deps) { - term = deps.term; - inlineInput = deps.inlineInput; - mentionSuggestions = deps.mentionSuggestions; - writeClickable = deps.writeClickable; - writePrompt = deps.writePrompt; - showInlineInput = deps.showInlineInput; - positionInlineInput = deps.positionInlineInput; - submitInlineInput = deps.submitInlineInput; - } - - // Set up event handlers - setupEventHandlers(); -} - -/** - * Update mxClient with new session credentials - * @param {Object} session - Session data with accessToken and userId - */ -export function updateClientSession(session) { - if (!mxClient) return; - - mxClient.accessToken = session.accessToken; - mxClient.userId = session.userId; -} - -/** - * Setup mxjs-lite event handlers - */ -function setupEventHandlers() { - if (!mxClient) return; - - mxClient.on('message', ({ roomId, event }) => { - if (!chatMode.active || roomId !== window.matrixSession?.roomId) return; - handleNewMessage(event); - }); - - mxClient.on('edit', ({ roomId, edits, newBody, event }) => { - if (!chatMode.active || roomId !== window.matrixSession?.roomId) return; - handleMessageEdit(edits, newBody); - }); - - mxClient.on('redaction', ({ roomId, redacts, event }) => { - if (!chatMode.active || roomId !== window.matrixSession?.roomId) return; - handleMessageRedaction(redacts); - }); - - mxClient.on('typing', ({ roomId, userIds }) => { - // Can add typing indicators here if needed - }); -} - -/** - * Handle incoming message event - * @param {Object} event - Matrix message event - */ -function handleNewMessage(event) { - if (event.content?.msgtype !== 'm.text') return; - - const msgId = event.event_id; - const exists = chatMode.messages.find(m => m.id === msgId); - if (exists) return; - - const timestamp = new Date(event.origin_server_ts); - const time = timestamp.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' - }); - - const userId = event.sender; - const displayName = chatMode.displayNames[userId] || mxClient.extractLocalpart(userId); - - const newMessage = { - id: msgId, - time: time, - sender: displayName, - userId: userId, - text: event.content.body - }; - - chatMode.messages.push(newMessage); - - // Only keep last 100 messages in memory - if (chatMode.messages.length > 100) { - chatMode.messages = chatMode.messages.slice(-100); - } - - renderChatMessage(newMessage); -} - -/** - * Handle message edit event - * @param {string} originalEventId - ID of the original message being edited - * @param {string} newBody - New message text - */ -function handleMessageEdit(originalEventId, newBody) { - const message = chatMode.messages.find(m => m.id === originalEventId); - if (!message) return; - - message.text = newBody + ' \x1b[90m(edited)\x1b[0m'; - rerenderChatView(); -} - -/** - * Handle message redaction event - * @param {string} redactedEventId - ID of the message being redacted - */ -function handleMessageRedaction(redactedEventId) { - const messageIndex = chatMode.messages.findIndex(m => m.id === redactedEventId); - if (messageIndex === -1) return; - - // Remove the message from the array - chatMode.messages.splice(messageIndex, 1); - rerenderChatView(); -} - -/** - * Re-render the entire chat view - */ -function rerenderChatView() { - if (!chatMode.active) return; - - // Clear terminal and redraw header - term.clear(); - term.writeln('╔════════════════════════════════════════════════════════════╗'); - term.writeln('║ CHAT - #generalchat ║'); - term.writeln('║ Type /help for commands ║'); - term.writeln('╚════════════════════════════════════════════════════════════╝'); - - // Render the last 20 messages - const recent = chatMode.messages.slice(-20); - recent.forEach(msg => { - const color = getUserColor(msg.sender); - term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); - }); - - // Render separator and prompt - term.writeln(''); - term.writeln('─'.repeat(term.cols || 60)); - term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); - term.scrollToBottom(); - setTimeout(() => positionInlineInput(), 10); -} - -// Chat mode state -export const chatMode = { - active: false, - messages: [], - lastSync: null, - pollInterval: null, - inputLine: '', - displayNames: {}, - mentionDirectory: [], - mentionLoadedAt: 0, - mentionAutocomplete: { - tokenStart: -1, - tokenEnd: -1, - matches: [], - index: 0 - } -}; - -/** - * Matrix API helper for backward compatibility - * @param {string} endpoint - API endpoint path - * @param {string} [method='GET'] - HTTP method - * @param {Object|null} [body=null] - Request body - * @returns {Promise} API response - */ -export const matrixApi = async (endpoint, method = 'GET', body = null) => { - if (!mxClient) return null; - - const accessToken = window.matrixSession?.accessToken || null; - return await mxClient.api(endpoint, method, body, accessToken); -}; - -/** - * Fetch the latest public message from a room - * @param {string} roomAlias - Room alias (e.g. #room:server.com) - * @returns {Promise<{sender: string, body: string, timestamp: number} | null>} - */ -export async function fetchPublicLastMessage(roomAlias) { - if (!mxClient) return null; - return await mxClient.fetchPublicLastMessage(roomAlias); -} - -/** - * Fetch user presence - * @param {string} userId - Full Matrix user ID - * @returns {Promise<{presence: string, lastActive: number} | null>} - */ -export async function fetchPublicPresence(userId) { - if (!mxClient) return null; - const result = await mxClient.fetchPublicPresence(userId); - return result ? { presence: result.presence, lastActive: result.lastActive } : null; -} - -/** - * Format a unix-millisecond timestamp as relative age text. - * @param {number} timestampMs - Epoch timestamp in milliseconds - * @returns {string} Relative time label - */ -export function formatTimeAgo(timestampMs) { - if (typeof timestampMs !== 'number') { - return 'unknown'; - } - - const elapsedSeconds = Math.max(0, Math.floor((Date.now() - timestampMs) / 1000)); - if (elapsedSeconds < 60) { - return 'just now'; - } - - if (elapsedSeconds < 3600) { - return `${Math.floor(elapsedSeconds / 60)}m ago`; - } - - if (elapsedSeconds < 86400) { - return `${Math.floor(elapsedSeconds / 3600)}h ago`; - } - - return `${Math.floor(elapsedSeconds / 86400)}d ago`; -} - -/** - * Get display name for a user (with caching) - * @param {string} userId - Matrix user ID - * @returns {Promise} Display name - */ -async function getDisplayName(userId) { - if (chatMode.displayNames[userId]) { - return chatMode.displayNames[userId]; - } - - if (!mxClient) { - const fallback = userId.split(':')[0].substring(1); - chatMode.displayNames[userId] = fallback; - return fallback; - } - - try { - const profile = await mxClient.getProfile(userId); - const displayName = profile?.displayName || mxClient.extractLocalpart(userId); - chatMode.displayNames[userId] = displayName; - return displayName; - } catch (error) { - const fallback = mxClient.extractLocalpart(userId); - chatMode.displayNames[userId] = fallback; - return fallback; - } -} - -/** - * Get color for user based on their ID (consistent hashing) - * @param {string} username - Username string - * @returns {string} ANSI color code - */ -function getUserColor(username) { - const colors = [ - '\x1b[91m', // bright red - '\x1b[92m', // bright green - '\x1b[93m', // bright yellow - '\x1b[94m', // bright blue - '\x1b[95m', // bright magenta - '\x1b[96m', // bright cyan - '\x1b[33m', // yellow - '\x1b[35m', // magenta - '\x1b[36m', // cyan - ]; - - let hash = 0; - for (let i = 0; i < username.length; i++) { - hash = ((hash << 5) - hash) + username.charCodeAt(i); - hash = hash & hash; - } - - return colors[Math.abs(hash) % colors.length]; -} - -/** - * Check if display name is already taken - * @param {string} newName - Proposed display name - * @returns {Promise} True if taken - */ -export async function isDisplayNameTaken(newName) { - if (!mxClient || !window.matrixSession?.roomId) return false; - - try { - const members = await mxClient.getRoomMembers(window.matrixSession.roomId); - if (!members) return false; - - for (const member of members) { - if (member.userId === window.matrixSession.userId) continue; - if (member.displayName.toLowerCase() === newName.toLowerCase()) { - return true; - } - } - return false; - } catch (error) { - console.error('Error checking display names:', error); - return false; - } -} - -/** - * Determine if a Matrix display name appears unchanged/default. - * @param {string} displayName - Current display name - * @returns {boolean} True when name looks like an auto-generated default - */ -function isDefaultDisplayName(displayName) { - if (!displayName) return true; - - const trimmedName = displayName.trim(); - if (!trimmedName) return true; - - const uuidV4Pattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - return uuidV4Pattern.test(trimmedName); -} - -/** - * Check whether to show a nickname setup hint for the current user. - * @returns {Promise} True when the user should be prompted to change nickname - */ -async function shouldShowNicknameHint() { - if (!mxClient || !window.matrixSession?.userId) return false; - - try { - const profile = await mxClient.getProfile(window.matrixSession.userId); - return isDefaultDisplayName(profile?.displayName || ''); - } catch (error) { - return false; - } -} - -/** - * Sync mention directory for autocomplete and mention rendering. - * @param {boolean} [force=false] - When true, bypass cache time - * @returns {Promise} - */ -async function syncMentionDirectory(force = false) { - if (!mxClient || !window.matrixSession?.roomId) return; - - const now = Date.now(); - if (!force && chatMode.mentionDirectory.length > 0 && (now - chatMode.mentionLoadedAt) < 30000) { - return; - } - - try { - const members = await mxClient.getRoomMembers(window.matrixSession.roomId); - if (!members) return; - - const directory = []; - for (const member of members) { - chatMode.displayNames[member.userId] = member.displayName; - directory.push({ userId: member.userId, displayName: member.displayName }); - } - - chatMode.mentionDirectory = directory; - chatMode.mentionLoadedAt = now; - } catch (error) { - console.warn('Failed to sync mention directory:', error); - } -} - -/** - * Get mention autocomplete matches for query. - * @param {string} query - Partial display name text without @ - * @returns {Array<{userId: string, displayName: string}>} Sorted matches - */ -function getMentionMatches(query) { - const queryLower = query.toLowerCase(); - const matches = chatMode.mentionDirectory.filter((entry) => { - const display = entry.displayName.toLowerCase(); - const localPart = entry.userId.split(':')[0].substring(1).toLowerCase(); - return display.startsWith(queryLower) || localPart.startsWith(queryLower); - }); - - matches.sort((left, right) => left.displayName.localeCompare(right.displayName)); - return matches; -} - -/** - * Escape text for HTML output. - * @param {string} value - Raw text - * @returns {string} HTML-escaped text - */ -function escapeHtml(value) { - return String(value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -/** - * Extract current mention token context from the input cursor. - * @returns {{atIndex: number, cursorPosition: number, tokenText: string} | null} Token info or null - */ -function getMentionTokenContext() { - const cursorPosition = inlineInput.selectionStart; - const fullValue = inlineInput.value; - const textBeforeCursor = fullValue.slice(0, cursorPosition); - const tokenStart = textBeforeCursor.search(/(?:^|\s)@[^\s]*$/); - - if (tokenStart === -1) return null; - - const atIndex = textBeforeCursor.indexOf('@', tokenStart); - if (atIndex === -1) return null; - - const tokenText = textBeforeCursor.slice(atIndex + 1); - if (!tokenText || tokenText.includes(':')) return null; - - return { atIndex, cursorPosition, tokenText }; -} - -/** - * Render mention suggestions above the inline input. - * @returns {void} - */ -function renderMentionSuggestions() { - const { matches, index } = chatMode.mentionAutocomplete; - if (!chatMode.active || matches.length === 0) { - mentionSuggestions.style.display = 'none'; - return; - } - - const limitedMatches = matches.slice(0, 5); - mentionSuggestions.innerHTML = limitedMatches.map((entry, entryIndex) => { - const selectedClass = index >= 0 && entryIndex === (index % limitedMatches.length) ? ' selected' : ''; - return `@${escapeHtml(entry.displayName)}`; - }).join(''); - mentionSuggestions.style.display = 'block'; - positionInlineInput(); -} - -/** - * Check whether mention suggestions are currently visible. - * @returns {boolean} True when suggestion strip is active - */ -export function hasVisibleMentionSuggestions() { - return chatMode.active - && mentionSuggestions.style.display !== 'none' - && chatMode.mentionAutocomplete.matches.length > 0; -} - -/** - * Hide mention suggestion UI and clear state. - * @returns {void} - */ -export function resetMentionAutocomplete() { - chatMode.mentionAutocomplete = { - tokenStart: -1, - tokenEnd: -1, - matches: [], - index: 0 - }; - mentionSuggestions.style.display = 'none'; - mentionSuggestions.innerHTML = ''; -} - -/** - * Refresh mention suggestions based on current cursor token. - * @returns {Promise} - */ -export async function refreshMentionSuggestionsFromInput() { - if (!chatMode.active) { - resetMentionAutocomplete(); - return; - } - - const context = getMentionTokenContext(); - if (!context) { - resetMentionAutocomplete(); - return; - } - - await syncMentionDirectory(); - const matches = getMentionMatches(context.tokenText); - if (matches.length === 0) { - resetMentionAutocomplete(); - return; - } - - chatMode.mentionAutocomplete = { - tokenStart: context.atIndex, - tokenEnd: context.cursorPosition, - matches, - index: -1 - }; - renderMentionSuggestions(); -} - -/** - * Replace selected mention token in input with selected display name. - * @param {{userId: string, displayName: string}} selectedEntry - Selected mention entry - * @returns {void} - */ -function applyMentionReplacement(selectedEntry) { - const rangeStart = chatMode.mentionAutocomplete.tokenStart; - const rangeEnd = chatMode.mentionAutocomplete.tokenEnd; - if (rangeStart < 0 || rangeEnd < 0) return; - - const fullValue = inlineInput.value; - const valueBeforeMention = fullValue.slice(0, rangeStart); - const valueAfterMention = fullValue.slice(rangeEnd); - const mentionText = `@${selectedEntry.displayName}`; - const hasTrailingSpace = valueAfterMention.startsWith(' '); - const nextValue = `${valueBeforeMention}${mentionText}${hasTrailingSpace ? '' : ' '}${valueAfterMention}`; - const nextCursor = valueBeforeMention.length + mentionText.length + (hasTrailingSpace ? 0 : 1); - - inlineInput.value = nextValue; - inlineInput.setSelectionRange(nextCursor, nextCursor); - chatMode.inputLine = nextValue; - chatMode.mentionAutocomplete.tokenEnd = nextCursor; -} - -/** - * Commit the currently selected mention suggestion into input text. - * @returns {boolean} True when a suggestion was applied - */ -export function commitSelectedMentionSuggestion() { - if (!hasVisibleMentionSuggestions()) return false; - - if (chatMode.mentionAutocomplete.index < 0) { - chatMode.mentionAutocomplete.index = 0; - } - - const selectedEntry = chatMode.mentionAutocomplete.matches[chatMode.mentionAutocomplete.index]; - applyMentionReplacement(selectedEntry); - resetMentionAutocomplete(); - return true; -} - -/** - * Resolve typed @displayname mentions to Matrix user IDs before sending. - * @param {string} message - Outgoing message text - * @returns {string} Message with mentions converted to @user:server - */ -function transformOutgoingMentions(message) { - if (!message || !chatMode.mentionDirectory.length) return message; - - return message.replace(/(^|\s)@([^\s:]+)\b/g, (fullMatch, leadingWhitespace, mentionValue) => { - const matchedEntry = chatMode.mentionDirectory.find((entry) => { - return entry.displayName.toLowerCase() === mentionValue.toLowerCase(); - }); - - if (!matchedEntry) return fullMatch; - - return `${leadingWhitespace}${matchedEntry.userId}`; - }); -} - -/** - * Check whether canonical Matrix mentions exist in text. - * @param {string} text - Message text - * @returns {boolean} True when one or more @user:server mentions are present - */ -function hasCanonicalMentions(text) { - return /@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/.test(text); -} - -/** - * Build formatted HTML body with matrix.to links for mentions. - * @param {string} resolvedBody - Message body with canonical mention IDs - * @returns {string} HTML formatted body - */ -function buildFormattedMentionBody(resolvedBody) { - const mentionRegex = /@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/g; - return escapeHtml(resolvedBody).replace(mentionRegex, (matchedValue, userBody) => { - const userId = `@${userBody}`; - const knownDisplayName = chatMode.displayNames[userId]; - const fallbackDisplayName = userId.split(':')[0].substring(1); - const displayName = knownDisplayName || fallbackDisplayName; - const matrixToUrl = `https://matrix.to/#/${userId}`; - return `@${escapeHtml(displayName)}`; - }); -} - -/** - * Build Matrix message content with mention metadata and formatted HTML. - * @param {string} message - Outgoing raw message - * @returns {Object} Matrix content payload - */ -function buildMentionMessageContent(message) { - const resolvedBody = transformOutgoingMentions(message); - const hasMentions = hasCanonicalMentions(resolvedBody); - - if (!hasMentions) { - return { msgtype: 'm.text', body: resolvedBody }; - } - - return { - msgtype: 'm.text', - body: resolvedBody, - 'm.mentions': {}, - format: 'org.matrix.custom.html', - formatted_body: buildFormattedMentionBody(resolvedBody) - }; -} - -/** - * Render Matrix user mentions as highlighted @displayname values. - * @param {string} text - Chat message body - * @returns {string} Formatted message text for terminal output - */ -function formatChatTextWithMentions(text) { - if (!text) return ''; - - return text.replace(/@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/g, (matchValue, userBody) => { - const userId = `@${userBody}`; - const knownDisplayName = chatMode.displayNames[userId]; - const fallbackDisplayName = userId.split(':')[0].substring(1); - const displayName = knownDisplayName || fallbackDisplayName; - return `\x1b[1;96m@${displayName}\x1b[0m`; - }); -} - -/** - * Cycle mention suggestions and apply selected display name. - * @returns {Promise} - */ -export async function applyMentionAutocomplete() { - if (!chatMode.active) return; - - if (chatMode.mentionAutocomplete.matches.length === 0) { - await refreshMentionSuggestionsFromInput(); - if (chatMode.mentionAutocomplete.matches.length === 0) return; - chatMode.mentionAutocomplete.index = 0; - } else { - chatMode.mentionAutocomplete.index = (chatMode.mentionAutocomplete.index + 1) - % chatMode.mentionAutocomplete.matches.length; - } - - const selectedEntry = chatMode.mentionAutocomplete.matches[chatMode.mentionAutocomplete.index]; - applyMentionReplacement(selectedEntry); - renderMentionSuggestions(); -} - -/** - * Enter chat mode - * @returns {Promise} - */ -export async function enterChatMode() { - if (!window.matrixSession || !window.matrixSession.roomId) { - term.writeln('\r\n Error: Not connected to chat. Use "chat" command first.\r\n'); - return; - } - - chatMode.active = true; - chatMode.messages = []; - chatMode.lastSync = null; - chatMode.inputLine = ''; - chatMode.mentionAutocomplete = { - tokenStart: -1, - tokenEnd: -1, - matches: [], - index: 0 - }; - resetMentionAutocomplete(); - await syncMentionDirectory(true); - - term.clear(); - term.writeln('╔════════════════════════════════════════════════════════════╗'); - term.writeln('║ CHAT - #generalchat ║'); - term.writeln('║ Type /help for commands ║'); - term.writeln('╚════════════════════════════════════════════════════════════╝'); - - // Fetch initial messages - await syncChatMessages(); - - // Show nickname setup hint in message history when display name is still default - const showNicknameHint = await shouldShowNicknameHint(); - if (showNicknameHint) { - const hintTime = new Date().toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' - }); - term.writeln(`\x1b[90m[${hintTime}]\x1b[0m \x1b[93mSystem:\x1b[0m You are using a default name. Use /nick [name] to change it.`); - } - - // Start sync loop - startSyncLoop(); - - // Add separator and initial prompt - term.writeln(''); - term.writeln('─'.repeat(term.cols || 60)); - term.write('\x1b[1;32m>\x1b[0m '); - - // Delay showing input to ensure terminal has rendered and cursor is positioned - setTimeout(() => { - showInlineInput(); - }, 50); - - updateQuickCommands('chat'); -} - -/** - * Start Matrix sync loop - */ -function startSyncLoop() { - if (!mxClient) return; - - chatMode.pollInterval = setInterval(async () => { - try { - const data = await mxClient.sync(syncToken, 10000); - if (data) { - syncToken = data.next_batch; - mxClient.processSyncData(data); - } - } catch (error) { - console.error('Sync error:', error); - } - }, 3000); -} - -/** - * Sync messages from Matrix (initial load) - * @param {boolean} [onlyNew=false] - Only fetch new messages - * @returns {Promise} - */ -async function syncChatMessages(onlyNew = false) { - if (!mxClient || !window.matrixSession?.roomId) return; - - try { - const result = await mxClient.getMessages(window.matrixSession.roomId, { limit: 50, dir: 'b' }); - if (!result || !result.messages) return; - - const newMessages = []; - for (const event of result.messages.reverse()) { - if (event.type === 'm.room.message' && event.content?.msgtype === 'm.text') { - const msgId = event.event_id; - const exists = chatMode.messages.find(m => m.id === msgId); - - if (!exists) { - const timestamp = new Date(event.origin_server_ts); - const time = timestamp.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' - }); - - const displayName = await getDisplayName(event.sender); - - newMessages.push({ - id: msgId, - time: time, - sender: displayName, - userId: event.sender, - text: event.content.body - }); - } - } - } - - if (newMessages.length > 0) { - chatMode.messages.push(...newMessages); - - if (chatMode.messages.length > 100) { - chatMode.messages = chatMode.messages.slice(-100); - } - - if (!onlyNew && chatMode.active) { - const recent = chatMode.messages.slice(-20); - recent.forEach(msg => { - const color = getUserColor(msg.sender); - term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); - }); - } - } - - chatMode.lastSync = Date.now(); - } catch (error) { - console.error('Sync error:', error); - } -} - -/** - * Render a chat message (insert above the prompt area) - * @param {Object} msg - Message object - */ -function renderChatMessage(msg) { - const color = getUserColor(msg.sender); - - term.write('\x1b[1A\x1b[2K\r'); - term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); - - if (msg.text.startsWith('/samsay ')) { - const samMessage = msg.text.substring(8).trim(); - if (samMessage && typeof window.samSpeak === 'function') { - window.samSpeak(samMessage); - } - } - - term.writeln('─'.repeat(term.cols || 60)); - term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); - term.scrollToBottom(); - setTimeout(() => positionInlineInput(), 10); -} - -/** - * Exit chat mode - */ -export function exitChatMode() { - chatMode.active = false; - if (chatMode.pollInterval) { - clearInterval(chatMode.pollInterval); - chatMode.pollInterval = null; - } - chatMode.displayNames = {}; - chatMode.mentionDirectory = []; - chatMode.mentionLoadedAt = 0; - resetMentionAutocomplete(); - term.clear(); - term.writeln(' Exited chat mode.'); - writePrompt(); - showInlineInput(); - updateQuickCommands('terminal'); -} - -/** - * Update quick command buttons based on context - * @param {string} mode - 'terminal' or 'chat' - */ -export function updateQuickCommands(mode) { - const container = document.querySelector('.quick-commands'); - const statusLeft = document.querySelector('.status-left'); - if (!container) return; - - if (mode === 'chat') { - if (statusLeft) statusLeft.textContent = 'Chat Mode'; - container.innerHTML = ` - - - - - `; - } else if (mode === 'game') { - if (statusLeft) statusLeft.textContent = 'Number Match'; - container.innerHTML = ''; - } else { - if (statusLeft) statusLeft.textContent = 'Ready'; - container.innerHTML = ` - - - - - `; - } -} - -/** - * Run a game command from a button click - * @param {string} command - The game command to submit - * @returns {void} - */ -export function runGameCommand(command) { - if (!inlineInput || !submitInlineInput) return; - inlineInput.value = command; - submitInlineInput(); -} - -/** - * Run a chat command from button click - * @param {string} command - The chat command to run - */ -export function runChatCommand(command) { - if (!chatMode.active) return; - - if (command === '/nick') { - inlineInput.value = '/nick '; - chatMode.inputLine = inlineInput.value; - resetMentionAutocomplete(); - inlineInput.focus(); - return; - } - - if (command === '/samsay') { - inlineInput.value = '/samsay '; - chatMode.inputLine = inlineInput.value; - resetMentionAutocomplete(); - inlineInput.focus(); - return; - } - - inlineInput.value = command; - submitInlineInput(); -} - -/** - * Render chat input prompt (efficiently) - */ -export function renderChatPrompt() { - term.write('\r\x1b[K'); - term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); -} - -/** - * Send chat message - * @param {string} message - Message to send - * @returns {Promise} - */ -export async function sendChatMessage(message) { - if (!message.trim()) return; - if (!mxClient || !window.matrixSession?.roomId) return; - - try { - await syncMentionDirectory(); - const content = buildMentionMessageContent(message); - - const result = await mxClient.sendMessage( - window.matrixSession.roomId, - content.body, - content.formatted_body || null - ); - - if (!result) { - throw new Error('Failed to send message'); - } - - chatMode.inputLine = ''; - resetMentionAutocomplete(); - renderChatPrompt(); - setTimeout(() => positionInlineInput(), 10); - } catch (error) { - term.write('\x1b[1A\x1b[2K\r'); - term.writeln(`\x1b[31mError: ${error.message}\x1b[0m`); - term.writeln('─'.repeat(term.cols || 60)); - term.write(`\x1b[1;32m>\x1b[0m `); - setTimeout(() => positionInlineInput(), 10); - } -} diff --git a/website/matrix-client.old.js b/website/matrix-client.old.js deleted file mode 100644 index 64b63a5..0000000 --- a/website/matrix-client.old.js +++ /dev/null @@ -1,1133 +0,0 @@ -/** - * Matrix Client - Generic Matrix protocol library - */ - -// Configuration -let config = { - homeserver: 'https://matrix.org', - bridgeUrl: null, - useBridge: false, - publicReadToken: null -}; - -// Dependencies that will be injected -let term, inlineInput, mentionSuggestions, writeClickable, writePrompt, showInlineInput, positionInlineInput, submitInlineInput; - -/** - * Initialize Matrix client with configuration and dependencies - * @param {Object} userConfig - Configuration options - * @param {string} userConfig.homeserver - Matrix homeserver URL - * @param {string} [userConfig.bridgeUrl] - Bridge iframe URL for CSP-restricted hosts - * @param {boolean} [userConfig.useBridge] - Whether to use iframe bridge - * @param {string} [userConfig.publicReadToken] - Public read token for unauthenticated requests - * @param {Object} deps - UI dependencies - */ -export function initMatrixClient(userConfig, deps) { - // Merge config - config = { ...config, ...userConfig }; - - // Inject dependencies - if (deps) { - term = deps.term; - inlineInput = deps.inlineInput; - mentionSuggestions = deps.mentionSuggestions; - writeClickable = deps.writeClickable; - writePrompt = deps.writePrompt; - showInlineInput = deps.showInlineInput; - positionInlineInput = deps.positionInlineInput; - submitInlineInput = deps.submitInlineInput; - } -} - -// Chat mode state -export const chatMode = { - active: false, - messages: [], - lastSync: null, - pollInterval: null, - inputLine: '', - displayNames: {}, // Cache for display names - mentionDirectory: [], - mentionLoadedAt: 0, - mentionAutocomplete: { - tokenStart: -1, - tokenEnd: -1, - matches: [], - index: 0 - } -}; - -/** - * Matrix Bridge - Iframe-based communication for CSP-restricted hosts - */ -const matrixBridge = { - iframe: null, - ready: false, - pendingRequests: new Map(), - requestCounter: 0 -}; - -/** - * Initialize the Matrix iframe bridge - * @returns {Promise} True if bridge loaded successfully - */ -async function initMatrixBridge() { - if (!config.bridgeUrl) { - throw new Error('Bridge URL not configured'); - } - - if (matrixBridge.iframe) { - return matrixBridge.ready; - } - - return new Promise((resolve) => { - const iframe = document.createElement('iframe'); - iframe.src = config.bridgeUrl; - iframe.style.display = 'none'; - iframe.style.position = 'absolute'; - iframe.style.width = '0'; - iframe.style.height = '0'; - iframe.style.border = 'none'; - - const timeout = setTimeout(() => { - console.error('[Matrix Bridge] Failed to load iframe bridge'); - matrixBridge.ready = false; - resolve(false); - }, 10000); - - window.addEventListener('message', (event) => { - if (event.data && event.data.type === 'matrix:bridge:ready') { - clearTimeout(timeout); - matrixBridge.ready = true; - console.log('[Matrix Bridge] Iframe bridge ready'); - resolve(true); - } - }); - - document.body.appendChild(iframe); - matrixBridge.iframe = iframe; - }); -} - -/** - * Send a request to the Matrix bridge via postMessage - * @param {string} type - Request type (e.g. 'matrix:auth', 'matrix:sendMessage') - * @param {object} payload - Request payload - * @returns {Promise} Response from bridge - */ -function matrixBridgeRequest(type, payload) { - return new Promise((resolve, reject) => { - if (!matrixBridge.iframe || !matrixBridge.ready) { - reject(new Error('Matrix bridge not initialized')); - return; - } - - const requestId = `req_${++matrixBridge.requestCounter}`; - const timeout = setTimeout(() => { - matrixBridge.pendingRequests.delete(requestId); - reject(new Error('Matrix bridge request timeout')); - }, 30000); - - matrixBridge.pendingRequests.set(requestId, { resolve, reject, timeout }); - - matrixBridge.iframe.contentWindow.postMessage({ - type, - requestId, - payload - }, config.bridgeUrl); - }); -} - -/** - * Handle responses from the Matrix bridge - */ -window.addEventListener('message', (event) => { - if (config.bridgeUrl && event.origin !== new URL(config.bridgeUrl).origin) { - return; - } - - const { type, requestId, payload } = event.data; - - if (!type || !requestId || !type.includes(':response')) { - return; - } - - const pending = matrixBridge.pendingRequests.get(requestId); - if (pending) { - clearTimeout(pending.timeout); - matrixBridge.pendingRequests.delete(requestId); - pending.resolve(payload); - } -}); - -// Matrix API helper -export const matrixApi = async (endpoint, method = 'GET', body = null) => { - if (!window.matrixSession) return null; - - // Use iframe bridge if configured - if (config.useBridge) { - if (!matrixBridge.ready) { - const initialized = await initMatrixBridge(); - if (!initialized) { - return { - errcode: 'M_BRIDGE_UNAVAILABLE', - error: 'Matrix bridge failed to initialize' - }; - } - } - - try { - const result = await matrixBridgeRequest('matrix:api', { - endpoint, - method, - body, - accessToken: window.matrixSession.accessToken - }); - return result; - } catch (error) { - return { - errcode: 'M_BRIDGE_ERROR', - error: error.message - }; - } - } - - // Direct API call - const url = `${config.homeserver}/_matrix/client/r0${endpoint}`; - const headers = { - 'Content-Type': 'application/json' - }; - - if (window.matrixSession.accessToken) { - headers['Authorization'] = `Bearer ${window.matrixSession.accessToken}`; - } - - const options = { method, headers }; - if (body) options.body = JSON.stringify(body); - - const response = await fetch(url, options); - return response.json(); -}; - -/** - * Fetch the latest public message from a room - * @param {string} roomAlias - Room alias (e.g. #room:server.com) - * @returns {Promise<{sender: string, body: string, timestamp: number} | null>} - */ -export async function fetchPublicLastMessage(roomAlias) { - if (!config.publicReadToken) { - console.warn('No public read token configured'); - return null; - } - - try { - // Use iframe bridge if configured - if (config.useBridge) { - if (!matrixBridge.ready) { - await initMatrixBridge(); - } - - const result = await matrixBridgeRequest('matrix:fetchLastMessage', { - roomAlias, - publicToken: config.publicReadToken - }); - - if (!result || result.error || !Array.isArray(result.chunk)) { - return null; - } - - const lastEvent = result.chunk.find(e => - e && e.type === 'm.room.message' && e.content && e.content.body - ); - - if (!lastEvent) return null; - - return { - sender: lastEvent.sender, - body: lastEvent.content.body, - timestamp: lastEvent.origin_server_ts || Date.now() - }; - } - - // Direct API call - const resolvedAlias = encodeURIComponent(roomAlias); - const roomResponse = await fetch( - `${config.homeserver}/_matrix/client/r0/directory/room/${resolvedAlias}`, - { headers: { 'Authorization': `Bearer ${config.publicReadToken}` } } - ); - - if (!roomResponse.ok) return null; - - const roomData = await roomResponse.json(); - const roomId = roomData?.room_id; - if (!roomId) return null; - - const messagesResponse = await fetch( - `${config.homeserver}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/messages?dir=b&limit=10`, - { headers: { 'Authorization': `Bearer ${config.publicReadToken}` } } - ); - - if (!messagesResponse.ok) return null; - - const messagesData = await messagesResponse.json(); - const lastEvent = messagesData.chunk?.find(e => - e && e.type === 'm.room.message' && e.content && e.content.body - ); - - if (!lastEvent) return null; - - return { - sender: lastEvent.sender, - body: lastEvent.content.body, - timestamp: lastEvent.origin_server_ts || Date.now() - }; - } catch (error) { - console.error('Failed to fetch public last message:', error); - return null; - } -} - -/** - * Fetch user presence - * @param {string} userId - Full Matrix user ID - * @returns {Promise<{presence: string, lastActive: number} | null>} - */ -export async function fetchPublicPresence(userId) { - if (!config.publicReadToken) { - console.warn('No public read token configured'); - return null; - } - - try { - // Use iframe bridge if configured - if (config.useBridge) { - if (!matrixBridge.ready) { - await initMatrixBridge(); - } - - const result = await matrixBridgeRequest('matrix:fetchPresence', { - userId, - publicToken: config.publicReadToken - }); - - if (result && !result.error && result.presence) { - return { - presence: result.presence, - lastActive: result.last_active_ago || 0 - }; - } - return null; - } - - // Direct API call - const response = await fetch( - `${config.homeserver}/_matrix/client/r0/presence/${encodeURIComponent(userId)}/status`, - { headers: { 'Authorization': `Bearer ${config.publicReadToken}` } } - ); - - if (!response.ok) return null; - - const data = await response.json(); - return { - presence: data.presence, - lastActive: data.last_active_ago || 0 - }; - } catch (error) { - console.error('Failed to fetch presence:', error); - return null; - } -} - -/** - * Format a unix-millisecond timestamp as relative age text. - * @param {number} timestampMs - Epoch timestamp in milliseconds - * @returns {string} Relative time label - */ -export function formatTimeAgo(timestampMs) { - if (typeof timestampMs !== 'number') { - return 'unknown'; - } - - const elapsedSeconds = Math.max(0, Math.floor((Date.now() - timestampMs) / 1000)); - if (elapsedSeconds < 60) { - return 'just now'; - } - - if (elapsedSeconds < 3600) { - return `${Math.floor(elapsedSeconds / 60)}m ago`; - } - - if (elapsedSeconds < 86400) { - return `${Math.floor(elapsedSeconds / 3600)}h ago`; - } - - return `${Math.floor(elapsedSeconds / 86400)}d ago`; -} - -// Get display name for a user (with caching) -async function getDisplayName(userId) { - // Check cache first - if (chatMode.displayNames[userId]) { - return chatMode.displayNames[userId]; - } - - try { - const data = await matrixApi(`/profile/${encodeURIComponent(userId)}/displayname`, 'GET'); - const displayName = data.displayname || userId.split(':')[0].substring(1); - chatMode.displayNames[userId] = displayName; - return displayName; - } catch (error) { - // Fallback to username part - const fallback = userId.split(':')[0].substring(1); - chatMode.displayNames[userId] = fallback; - return fallback; - } -} - -// Get color for user based on their ID (consistent hashing) -function getUserColor(username) { - // Color palette that works well on dark green background - const colors = [ - '\x1b[91m', // bright red - '\x1b[92m', // bright green - '\x1b[93m', // bright yellow - '\x1b[94m', // bright blue - '\x1b[95m', // bright magenta - '\x1b[96m', // bright cyan - '\x1b[33m', // yellow - '\x1b[35m', // magenta - '\x1b[36m', // cyan - ]; - - // Simple hash function - let hash = 0; - for (let i = 0; i < username.length; i++) { - hash = ((hash << 5) - hash) + username.charCodeAt(i); - hash = hash & hash; // Convert to 32bit integer - } - - return colors[Math.abs(hash) % colors.length]; -} - -// Check if display name is already taken -export async function isDisplayNameTaken(newName) { - try { - const members = await matrixApi(`/rooms/${window.matrixSession.roomId}/joined_members`, 'GET'); - if (members && members.joined) { - for (const [userId, member] of Object.entries(members.joined)) { - // Skip our own user - if (userId === window.matrixSession.userId) continue; - - const displayName = member.display_name || userId.split(':')[0].substring(1); - if (displayName.toLowerCase() === newName.toLowerCase()) { - return true; - } - } - } - return false; - } catch (error) { - console.error('Error checking display names:', error); - return false; // Allow on error - } -} - -/** - * Determine if a Matrix display name appears unchanged/default. - * @param {string} displayName - Current display name - * @returns {boolean} True when name looks like an auto-generated default - */ -function isDefaultDisplayName(displayName) { - if (!displayName) { - return true; - } - - const trimmedName = displayName.trim(); - if (!trimmedName) { - return true; - } - - const uuidV4Pattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - return uuidV4Pattern.test(trimmedName); -} - -/** - * Check whether to show a nickname setup hint for the current user. - * @returns {Promise} True when the user should be prompted to change nickname - */ -async function shouldShowNicknameHint() { - if (!window.matrixSession || !window.matrixSession.userId) { - return false; - } - - try { - const encodedUserId = encodeURIComponent(window.matrixSession.userId); - const profileData = await matrixApi(`/profile/${encodedUserId}/displayname`, 'GET'); - const displayName = profileData && typeof profileData.displayname === 'string' - ? profileData.displayname - : ''; - - return isDefaultDisplayName(displayName); - } catch (error) { - return false; - } -} - -/** - * Sync mention directory for autocomplete and mention rendering. - * @param {boolean} force - When true, bypass cache time - * @returns {Promise} - */ -async function syncMentionDirectory(force = false) { - if (!window.matrixSession || !window.matrixSession.roomId) { - return; - } - - const now = Date.now(); - if (!force && chatMode.mentionDirectory.length > 0 && (now - chatMode.mentionLoadedAt) < 30000) { - return; - } - - try { - const members = await matrixApi(`/rooms/${window.matrixSession.roomId}/joined_members`, 'GET'); - if (!members || !members.joined) { - return; - } - - const directory = []; - Object.entries(members.joined).forEach(([userId, member]) => { - const fallbackName = userId.split(':')[0].substring(1); - const displayName = member && member.display_name ? member.display_name : fallbackName; - chatMode.displayNames[userId] = displayName; - directory.push({ userId, displayName }); - }); - - chatMode.mentionDirectory = directory; - chatMode.mentionLoadedAt = now; - } catch (error) { - // Ignore mention directory refresh failures. - } -} - -/** - * Get mention autocomplete matches for query. - * @param {string} query - Partial display name text without @ - * @returns {Array<{userId: string, displayName: string}>} Sorted matches - */ -function getMentionMatches(query) { - const queryLower = query.toLowerCase(); - const matches = chatMode.mentionDirectory.filter((entry) => { - const display = entry.displayName.toLowerCase(); - const localPart = entry.userId.split(':')[0].substring(1).toLowerCase(); - return display.startsWith(queryLower) || localPart.startsWith(queryLower); - }); - - matches.sort((left, right) => left.displayName.localeCompare(right.displayName)); - return matches; -} - -/** - * Escape text for HTML output. - * @param {string} value - Raw text - * @returns {string} HTML-escaped text - */ -function escapeHtml(value) { - return String(value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -/** - * Extract current mention token context from the input cursor. - * @returns {{atIndex: number, cursorPosition: number, tokenText: string} | null} Token info or null - */ -function getMentionTokenContext() { - const cursorPosition = inlineInput.selectionStart; - const fullValue = inlineInput.value; - const textBeforeCursor = fullValue.slice(0, cursorPosition); - const tokenStart = textBeforeCursor.search(/(?:^|\s)@[^\s]*$/); - - if (tokenStart === -1) { - return null; - } - - const atIndex = textBeforeCursor.indexOf('@', tokenStart); - if (atIndex === -1) { - return null; - } - - const tokenText = textBeforeCursor.slice(atIndex + 1); - if (!tokenText || tokenText.includes(':')) { - return null; - } - - return { atIndex, cursorPosition, tokenText }; -} - -/** - * Render mention suggestions above the inline input. - * @returns {void} - */ -function renderMentionSuggestions() { - const { matches, index } = chatMode.mentionAutocomplete; - if (!chatMode.active || matches.length === 0) { - mentionSuggestions.style.display = 'none'; - return; - } - - const limitedMatches = matches.slice(0, 5); - mentionSuggestions.innerHTML = limitedMatches.map((entry, entryIndex) => { - const selectedClass = index >= 0 && entryIndex === (index % limitedMatches.length) ? ' selected' : ''; - return `@${escapeHtml(entry.displayName)}`; - }).join(''); - mentionSuggestions.style.display = 'block'; - positionInlineInput(); -} - -/** - * Check whether mention suggestions are currently visible. - * @returns {boolean} True when suggestion strip is active - */ -export function hasVisibleMentionSuggestions() { - return chatMode.active - && mentionSuggestions.style.display !== 'none' - && chatMode.mentionAutocomplete.matches.length > 0; -} - -/** - * Hide mention suggestion UI and clear state. - * @returns {void} - */ -export function resetMentionAutocomplete() { - chatMode.mentionAutocomplete = { - tokenStart: -1, - tokenEnd: -1, - matches: [], - index: 0 - }; - mentionSuggestions.style.display = 'none'; - mentionSuggestions.innerHTML = ''; -} - -/** - * Refresh mention suggestions based on current cursor token. - * @returns {Promise} - */ -export async function refreshMentionSuggestionsFromInput() { - if (!chatMode.active) { - resetMentionAutocomplete(); - return; - } - - const context = getMentionTokenContext(); - if (!context) { - resetMentionAutocomplete(); - return; - } - - await syncMentionDirectory(); - const matches = getMentionMatches(context.tokenText); - if (matches.length === 0) { - resetMentionAutocomplete(); - return; - } - - chatMode.mentionAutocomplete = { - tokenStart: context.atIndex, - tokenEnd: context.cursorPosition, - matches, - index: -1 - }; - renderMentionSuggestions(); -} - -/** - * Replace selected mention token in input with selected display name. - * @param {{userId: string, displayName: string}} selectedEntry - Selected mention entry - * @returns {void} - */ -function applyMentionReplacement(selectedEntry) { - const rangeStart = chatMode.mentionAutocomplete.tokenStart; - const rangeEnd = chatMode.mentionAutocomplete.tokenEnd; - if (rangeStart < 0 || rangeEnd < 0) { - return; - } - - const fullValue = inlineInput.value; - const valueBeforeMention = fullValue.slice(0, rangeStart); - const valueAfterMention = fullValue.slice(rangeEnd); - const mentionText = `@${selectedEntry.displayName}`; - const hasTrailingSpace = valueAfterMention.startsWith(' '); - const nextValue = `${valueBeforeMention}${mentionText}${hasTrailingSpace ? '' : ' '}${valueAfterMention}`; - const nextCursor = valueBeforeMention.length + mentionText.length + (hasTrailingSpace ? 0 : 1); - - inlineInput.value = nextValue; - inlineInput.setSelectionRange(nextCursor, nextCursor); - chatMode.inputLine = nextValue; - chatMode.mentionAutocomplete.tokenEnd = nextCursor; -} - -/** - * Commit the currently selected mention suggestion into input text. - * @returns {boolean} True when a suggestion was applied - */ -export function commitSelectedMentionSuggestion() { - if (!hasVisibleMentionSuggestions()) { - return false; - } - - if (chatMode.mentionAutocomplete.index < 0) { - chatMode.mentionAutocomplete.index = 0; - } - - const selectedEntry = chatMode.mentionAutocomplete.matches[chatMode.mentionAutocomplete.index]; - applyMentionReplacement(selectedEntry); - resetMentionAutocomplete(); - return true; -} - -/** - * Resolve typed @displayname mentions to Matrix user IDs before sending. - * @param {string} message - Outgoing message text - * @returns {string} Message with mentions converted to @user:server - */ -function transformOutgoingMentions(message) { - if (!message || !chatMode.mentionDirectory.length) { - return message; - } - - return message.replace(/(^|\s)@([^\s:]+)\b/g, (fullMatch, leadingWhitespace, mentionValue) => { - const matchedEntry = chatMode.mentionDirectory.find((entry) => { - return entry.displayName.toLowerCase() === mentionValue.toLowerCase(); - }); - - if (!matchedEntry) { - return fullMatch; - } - - return `${leadingWhitespace}${matchedEntry.userId}`; - }); -} - -/** - * Build a plain Matrix text payload with canonical mention IDs. - * @param {string} message - Outgoing raw message - * @returns {{msgtype: string, body: string}} Matrix content payload - */ -function buildPlainMentionMessageContent(message) { - return { - msgtype: 'm.text', - body: transformOutgoingMentions(message) - }; -} - -/** - * Check whether canonical Matrix mentions exist in text. - * @param {string} text - Message text - * @returns {boolean} True when one or more @user:server mentions are present - */ -function hasCanonicalMentions(text) { - return /@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/.test(text); -} - -/** - * Build formatted HTML body with matrix.to links for mentions. - * @param {string} resolvedBody - Message body with canonical mention IDs - * @returns {string} HTML formatted body - */ -function buildFormattedMentionBody(resolvedBody) { - const mentionRegex = /@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/g; - return escapeHtml(resolvedBody).replace(mentionRegex, (matchedValue, userBody) => { - const userId = `@${userBody}`; - const knownDisplayName = chatMode.displayNames[userId]; - const fallbackDisplayName = userId.split(':')[0].substring(1); - const displayName = knownDisplayName || fallbackDisplayName; - const matrixToUrl = `https://matrix.to/#/${userId}`; - return `@${escapeHtml(displayName)}`; - }); -} - -/** - * Build rich mention payload without m.mentions for compatibility fallback. - * @param {string} message - Outgoing raw message - * @returns {{msgtype: string, body: string, format?: string, formatted_body?: string}} Matrix content payload - */ -function buildRichMentionFallbackContent(message) { - const resolvedBody = transformOutgoingMentions(message); - if (!hasCanonicalMentions(resolvedBody)) { - return { - msgtype: 'm.text', - body: resolvedBody - }; - } - - return { - msgtype: 'm.text', - body: resolvedBody, - format: 'org.matrix.custom.html', - formatted_body: buildFormattedMentionBody(resolvedBody) - }; -} - -/** - * Build Matrix message content with mention metadata and formatted HTML. - * @param {string} message - Outgoing raw message - * @returns {{msgtype: string, body: string, "m.mentions"?: object, format?: string, formatted_body?: string}} Matrix content payload - */ -function buildMentionMessageContent(message) { - const resolvedBody = transformOutgoingMentions(message); - const hasMentions = hasCanonicalMentions(resolvedBody); - - if (!hasMentions) { - return { - msgtype: 'm.text', - body: resolvedBody - }; - } - - return { - msgtype: 'm.text', - body: resolvedBody, - 'm.mentions': {}, - format: 'org.matrix.custom.html', - formatted_body: buildFormattedMentionBody(resolvedBody) - }; -} - -/** - * Render Matrix user mentions as highlighted @displayname values. - * @param {string} text - Chat message body - * @returns {string} Formatted message text for terminal output - */ -function formatChatTextWithMentions(text) { - if (!text) { - return ''; - } - - return text.replace(/@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/g, (matchValue, userBody) => { - const userId = `@${userBody}`; - const knownDisplayName = chatMode.displayNames[userId]; - const fallbackDisplayName = userId.split(':')[0].substring(1); - const displayName = knownDisplayName || fallbackDisplayName; - return `\x1b[1;96m@${displayName}\x1b[0m`; - }); -} - -/** - * Cycle mention suggestions and apply selected display name. - * @returns {Promise} - */ -export async function applyMentionAutocomplete() { - if (!chatMode.active) { - return; - } - - if (chatMode.mentionAutocomplete.matches.length === 0) { - await refreshMentionSuggestionsFromInput(); - if (chatMode.mentionAutocomplete.matches.length === 0) { - return; - } - chatMode.mentionAutocomplete.index = 0; - } else { - chatMode.mentionAutocomplete.index = (chatMode.mentionAutocomplete.index + 1) - % chatMode.mentionAutocomplete.matches.length; - } - - const selectedEntry = chatMode.mentionAutocomplete.matches[chatMode.mentionAutocomplete.index]; - applyMentionReplacement(selectedEntry); - renderMentionSuggestions(); -} - -// Enter chat mode -export async function enterChatMode() { - if (!window.matrixSession || !window.matrixSession.roomId) { - term.writeln('\r\n Error: Not connected to chat. Use "chat" command first.\r\n'); - return; - } - - chatMode.active = true; - chatMode.messages = []; - chatMode.lastSync = null; - chatMode.inputLine = ''; - chatMode.mentionAutocomplete = { - tokenStart: -1, - tokenEnd: -1, - matches: [], - index: 0 - }; - resetMentionAutocomplete(); - await syncMentionDirectory(true); - - term.clear(); - term.writeln('╔════════════════════════════════════════════════════════════╗'); - term.writeln('║ CHAT - #generalchat ║'); - term.writeln('║ Type /help for commands ║'); - term.writeln('╚════════════════════════════════════════════════════════════╝'); - - // Fetch initial messages - await syncChatMessages(); - - // Show nickname setup hint in message history when display name is still default - const showNicknameHint = await shouldShowNicknameHint(); - if (showNicknameHint) { - const hintTime = new Date().toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' - }); - term.writeln(`\x1b[90m[${hintTime}]\x1b[0m \x1b[93mSystem:\x1b[0m You are using a default name. Use /nick [name] to change it.`); - } - - // Start polling for new messages - chatMode.pollInterval = setInterval(async () => { - await syncChatMessages(true); - }, 3000); - - // Add separator and initial prompt - term.writeln(''); - term.writeln('─'.repeat(term.cols || 60)); - term.write('\x1b[1;32m>\x1b[0m '); - - // Delay showing input to ensure terminal has rendered and cursor is positioned - setTimeout(() => { - showInlineInput(); - }, 50); - - updateQuickCommands('chat'); -} - -// Exit chat mode -export function exitChatMode() { - chatMode.active = false; - if (chatMode.pollInterval) { - clearInterval(chatMode.pollInterval); - chatMode.pollInterval = null; - } - chatMode.displayNames = {}; // Clear display name cache - chatMode.mentionDirectory = []; - chatMode.mentionLoadedAt = 0; - resetMentionAutocomplete(); - term.clear(); - term.writeln(' Exited chat mode.'); - writePrompt(); - showInlineInput(); - updateQuickCommands('terminal'); -} - -/** - * Update quick command buttons based on context - * @param {string} mode - 'terminal' or 'chat' - */ -export function updateQuickCommands(mode) { - const container = document.querySelector('.quick-commands'); - const statusLeft = document.querySelector('.status-left'); - if (!container) return; - - if (mode === 'chat') { - if (statusLeft) statusLeft.textContent = 'Chat Mode'; - container.innerHTML = ` - - - - - `; - } else { - if (statusLeft) statusLeft.textContent = 'Ready'; - container.innerHTML = ` - - - - - `; - } -} - -/** - * Run a chat command from button click - * @param {string} command - The chat command to run - */ -export function runChatCommand(command) { - if (!chatMode.active) return; - - if (command === '/nick') { - // For /nick, just fill in the command prefix - inlineInput.value = '/nick '; - chatMode.inputLine = inlineInput.value; - resetMentionAutocomplete(); - inlineInput.focus(); - return; - } - - if (command === '/samsay') { - // For /samsay, just fill in the command prefix - inlineInput.value = '/samsay '; - chatMode.inputLine = inlineInput.value; - resetMentionAutocomplete(); - inlineInput.focus(); - return; - } - - // Execute the command - inlineInput.value = command; - submitInlineInput(); -} - -// Sync messages from Matrix -async function syncChatMessages(onlyNew = false) { - try { - let endpoint = `/rooms/${window.matrixSession.roomId}/messages?dir=b&limit=50`; - const data = await matrixApi(endpoint); - - if (!data || !data.chunk) return; - - const newMessages = []; - for (const event of data.chunk.reverse()) { - if (event.type === 'm.room.message' && event.content.msgtype === 'm.text') { - const msgId = event.event_id; - const exists = chatMode.messages.find(m => m.id === msgId); - - if (!exists) { - const timestamp = new Date(event.origin_server_ts); - const time = timestamp.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' - }); - - // Get display name for sender - const displayName = await getDisplayName(event.sender); - - newMessages.push({ - id: msgId, - time: time, - sender: displayName, - userId: event.sender, - text: event.content.body - }); - } - } - } - - if (newMessages.length > 0) { - chatMode.messages.push(...newMessages); - - // Only keep last 100 messages in memory - if (chatMode.messages.length > 100) { - chatMode.messages = chatMode.messages.slice(-100); - } - - // Render new messages if in chat mode and only updating - if (onlyNew && chatMode.active) { - newMessages.forEach(msg => { - renderChatMessage(msg); - }); - } else if (!onlyNew && chatMode.active) { - // Initial load - show last 20 messages (simple print, no cursor manipulation) - const recent = chatMode.messages.slice(-20); - recent.forEach(msg => { - const color = getUserColor(msg.sender); - term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); - }); - } - } - - chatMode.lastSync = Date.now(); - } catch (error) { - console.error('Sync error:', error); - } -} - -// Render a chat message (insert above the prompt area) -function renderChatMessage(msg) { - const color = getUserColor(msg.sender); - - // Move up to separator line and clear it and the prompt line - term.write('\x1b[1A\x1b[2K\r'); // Move up to separator, clear it - - // Write the new message - term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); - - // If message starts with /samsay, speak it - if (msg.text.startsWith('/samsay ')) { - const samMessage = msg.text.substring(8).trim(); - if (samMessage && typeof window.samSpeak === 'function') { - window.samSpeak(samMessage); - } - } - - // Redraw separator - term.writeln('─'.repeat(term.cols || 60)); - - // Redraw prompt with current input - term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); - - // Scroll to bottom to show new message - term.scrollToBottom(); - - // Reposition the inline input after terminal updates - setTimeout(() => positionInlineInput(), 10); -} - -// Render chat input prompt (efficiently) -export function renderChatPrompt() { - // Move to beginning of line, redraw prompt and input - term.write('\r\x1b[K'); // CR + clear rest of line - term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); -} - -// Send chat message -export async function sendChatMessage(message) { - if (!message.trim()) return; - - try { - await syncMentionDirectory(); - const content = buildMentionMessageContent(message); - const hasMentions = hasCanonicalMentions(content.body); - const txnId = Date.now().toString(); - let sendResult = await matrixApi( - `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}`, - 'PUT', - content - ); - - if (sendResult && sendResult.errcode && hasMentions) { - const richFallbackContent = buildRichMentionFallbackContent(message); - sendResult = await matrixApi( - `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}-richfallback`, - 'PUT', - richFallbackContent - ); - } - - if (sendResult && sendResult.errcode && !hasMentions) { - const fallbackContent = buildPlainMentionMessageContent(message); - sendResult = await matrixApi( - `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}-fallback`, - 'PUT', - fallbackContent - ); - } - - if (sendResult && sendResult.errcode) { - throw new Error(sendResult.error || sendResult.errcode || 'Failed to send message'); - } - - // Clear the input - chatMode.inputLine = ''; - resetMentionAutocomplete(); - renderChatPrompt(); - - // Reposition input after render - setTimeout(() => positionInlineInput(), 10); - - // Immediately sync to show our message - setTimeout(() => syncChatMessages(true), 500); - } catch (error) { - // Show error above separator - term.write('\x1b[1A\x1b[2K\r'); - term.writeln(`\x1b[31mError: ${error.message}\x1b[0m`); - term.writeln('─'.repeat(term.cols || 60)); - term.write(`\x1b[1;32m>\x1b[0m `); - setTimeout(() => positionInlineInput(), 10); - } -} diff --git a/website/mxjs-lite.js b/website/mxjs-lite.js deleted file mode 100644 index 0bbfc06..0000000 --- a/website/mxjs-lite.js +++ /dev/null @@ -1,1243 +0,0 @@ -const SANITIZE_ALLOWED_TAGS = new Set([ - "a", - "b", - "strong", - "i", - "em", - "code", - "del", - "s", - "strike", - "u", - "span", - "br", -]); - -const enc = encodeURIComponent; -const cerr = console.error.bind(console); -const M_MSG = "m.room.message"; -const M_MEMBER = "m.room.member"; -const M_REACT = "m.reaction"; -const M_REDACTION = "m.room.redaction"; -const M_RNAME = "m.room.name"; -const M_RTOPIC = "m.room.topic"; -const M_RAVATAR = "m.room.avatar"; -const M_REL = "m.relates_to"; -const M_NEWCONT = "m.new_content"; -const M_REPLACE = "m.replace"; -const M_ANNOT = "m.annotation"; -const M_LPWD = "m.login.password"; -const M_IDUSER = "m.id.user"; -const M_TEXT = "m.text"; -const M_IMAGE = "m.image"; -const M_HTML = "org.matrix.custom.html"; -const MATRIX_TO = "https://matrix.to/#/"; - -/** - * A lightweight Matrix client for interacting with the Matrix homeserver API. - */ -export class MxjsClient { - /** @type {Map>} */ - #handlers = new Map(); - - /** @type {Set} Tracks room IDs seen in sync responses to detect new joins. */ - #knownRoomIds = new Set(); - - /** - * @param {object} [options] - * @param {string} [options.homeserver="https://matrix.org"] - The Matrix homeserver base URL. - * @param {string|null} [options.publicReadToken=null] - Access token used for unauthenticated public read operations. - */ - constructor({ - homeserver = "https://matrix.org", - publicReadToken = null, - } = {}) { - this.homeserver = homeserver; - this.publicReadToken = publicReadToken; - this.accessToken = null; - this.userId = null; - } - - /** - * Makes a raw Matrix Client-Server API request. - * @param {string} endpoint - The endpoint path relative to `/_matrix/client/r0`. - * @param {string} [method="GET"] - HTTP method. - * @param {Object|null} [body=null] - Request body, serialized as JSON. - * @param {string|null} [accessToken=this.accessToken] - Bearer token override. - * @returns {Promise} The parsed JSON response. - */ - async api( - endpoint, - method = "GET", - body = null, - accessToken = this.accessToken, - ) { - const url = `${this.homeserver}/_matrix/client/r0${endpoint}`; - const headers = { "Content-Type": "application/json" }; - if (accessToken) headers.Authorization = `Bearer ${accessToken}`; - const options = { method, headers }; - if (body) options.body = JSON.stringify(body); - const response = await fetch(url, options); - const data = await response.json(); - if (data.errcode === "M_LIMIT_EXCEEDED") { - await new Promise((r) => setTimeout(r, data.retry_after_ms ?? 1000)); - return (await fetch(url, options)).json(); - } - return data; - } - - /** - * Performs a UIAA (User-Interactive Authentication) two-step POST request. - * @param {string} endpoint - API endpoint path. - * @param {Object} firstBody - Initial request body to retrieve the UIAA session. - * @param {function(string): Object} buildAuthBody - Callback receiving the session ID and returning the final auth body. - * @param {string|null} [accessToken=this.accessToken] - Bearer token override. - * @returns {Promise} The final response data. - */ - async #uiaaRequest( - endpoint, - firstBody, - buildAuthBody, - accessToken = this.accessToken, - ) { - const headers = { "Content-Type": "application/json" }; - if (accessToken) headers.Authorization = `Bearer ${accessToken}`; - const url = `${this.homeserver}/_matrix/client/r0${endpoint}`; - const initRes = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify(firstBody), - }); - const initData = await initRes.json(); - if (initRes.ok) return initData; - if (!initData.session) throw new Error(initData.error || initData.errcode); - const authData = await ( - await fetch(url, { - method: "POST", - headers, - body: JSON.stringify(buildAuthBody(initData.session)), - }) - ).json(); - if (authData.errcode) throw new Error(authData.error || authData.errcode); - return authData; - } - - /** - * Stores session credentials from a login or register response. - * @param {Object} data - Response containing `access_token` and `user_id`. - * @returns {{accessToken: string, userId: string}} - */ - #storeSession(data) { - this.accessToken = data.access_token; - this.userId = data.user_id; - const session = { accessToken: data.access_token, userId: data.user_id }; - this.emit('connect', session); - return session; - } - - /** - * Registers a new account on the homeserver. - * @param {string} username - * @param {string} password - * @returns {Promise<{accessToken: string, userId: string}|null>} Session info, or `null` on failure. - */ - async register(username, password) { - try { - return this.#storeSession( - await this.#uiaaRequest( - "/register", - { username, password }, - (s) => ({ - username, - password, - auth: { type: "m.login.dummy", session: s }, - }), - null, - ), - ); - } catch (e) { - cerr("register:", e); - return null; - } - } - - /** - * Registers an anonymous guest account on the homeserver. - * @returns {Promise<{accessToken: string, userId: string}|null>} Session info, or `null` on failure. - */ - async registerGuest() { - try { - const data = await this.api("/register?kind=guest", "POST", {}, null); - if (data.errcode) throw new Error(data.error || data.errcode); - return this.#storeSession(data); - } catch (e) { - cerr("guest:", e); - return null; - } - } - - /** - * Logs in with a username and password. - * @param {string} username - * @param {string} password - * @returns {Promise<{accessToken: string, userId: string}|null>} Session info, or `null` on failure. - */ - async login(username, password) { - try { - const data = await this.api( - "/login", - "POST", - { - type: M_LPWD, - identifier: { type: M_IDUSER, user: username }, - password, - }, - null, - ); - if (data.errcode) throw new Error(data.error || data.errcode); - return this.#storeSession(data); - } catch (e) { - cerr("login:", e); - return null; - } - } - - /** - * Clears the locally stored access token and user ID. - */ - logout() { - this.accessToken = null; - this.userId = null; - this.#knownRoomIds.clear(); - this.emit('disconnect'); - } - - /** - * Permanently deactivates the current user's account. - * @param {string} password - Current account password for UIAA confirmation. - * @returns {Promise} `true` on success. - */ - async deactivateAccount(password) { - try { - await this.#uiaaRequest("/account/deactivate", {}, (s) => ({ - auth: { - type: M_LPWD, - session: s, - identifier: { type: M_IDUSER, user: this.userId }, - password, - }, - })); - this.logout(); - return true; - } catch (e) { - cerr("deactivate:", e); - return false; - } - } - - /** - * Changes the current user's password. - * @param {string} oldPassword - * @param {string} newPassword - * @returns {Promise} `true` on success. - */ - async changePassword(oldPassword, newPassword) { - try { - await this.#uiaaRequest( - "/account/password", - { new_password: newPassword }, - (s) => ({ - new_password: newPassword, - auth: { - type: M_LPWD, - session: s, - identifier: { type: M_IDUSER, user: this.userId }, - password: oldPassword, - }, - }), - ); - return true; - } catch (e) { - cerr("password:", e); - return false; - } - } - - /** - * Fetches the display name and avatar URL for a user. - * @param {string} [userId=this.userId] - The user ID to look up. - * @returns {Promise<{displayName: string|null, avatarUrl: string|null}|null>} - */ - async getProfile(userId = this.userId) { - try { - const data = await this.api(`/profile/${enc(userId)}`); - return data.errcode - ? null - : { - displayName: data.displayname || null, - avatarUrl: data.avatar_url || null, - }; - } catch (e) { - cerr("profile:", e); - return null; - } - } - - /** - * Sets the display name for the current user. - * @param {string} displayName - * @returns {Promise} `true` on success. - */ - async setDisplayName(displayName) { - const result = await this.api( - `/profile/${this.userId}/displayname`, - "PUT", - { displayname: displayName }, - ); - return !result.errcode; - } - - /** - * Sets the avatar URL for the current user. - * @param {string} avatarUrl - An `mxc://` URI. - * @returns {Promise} `true` on success. - */ - async setAvatarUrl(avatarUrl) { - const result = await this.api(`/profile/${this.userId}/avatar_url`, "PUT", { - avatar_url: avatarUrl, - }); - return !result.errcode; - } - - /** - * Converts an `mxc://` URI to an HTTP download URL for the current homeserver. - * @param {string} mxcUrl - An `mxc://` URI. - * @returns {string|null} The HTTP URL, or `null` if the input is invalid. - */ - mxcToHttp(mxcUrl) { - if (!mxcUrl?.startsWith("mxc://")) return null; - return `${this.homeserver}/_matrix/media/r0/download/${mxcUrl.slice(6)}`; - } - - /** - * Resolves a room alias (e.g. `#room:server`) to a room ID. - * @param {string} roomAlias - * @returns {Promise} The room ID, or `null` on failure. - */ - async resolveRoomAlias(roomAlias) { - try { - return ( - (await this.api(`/directory/room/${enc(roomAlias)}`)).room_id || null - ); - } catch (e) { - cerr("alias:", e); - return null; - } - } - - /** - * Joins a room by its ID or alias. - * @param {string} roomIdOrAlias - * @returns {Promise<{roomId: string}|null>} The joined room ID, or `null` on failure. - */ - async joinRoom(roomIdOrAlias) { - try { - const result = await this.api(`/join/${enc(roomIdOrAlias)}`, "POST", {}); - return result.errcode ? null : { roomId: result.room_id }; - } catch (e) { - cerr("join:", e); - return null; - } - } - - /** - * Creates a new room. - * @param {Object} options - Room creation options passed directly to the Matrix API. - * @returns {Promise<{roomId: string}|null>} The new room ID, or `null` on failure. - */ - async createRoom(options) { - const result = await this.api("/createRoom", "POST", options); - if (result.errcode) { - cerr("create:", result.errcode); - return null; - } - return { roomId: result.room_id }; - } - - /** - * Leaves a room. - * @param {string} roomId - * @returns {Promise} `true` on success. - */ - async leaveRoom(roomId) { - try { - return !(await this.api(`/rooms/${roomId}/leave`, "POST", {})).errcode; - } catch (e) { - cerr("leave:", e); - return false; - } - } - - /** - * Invites a user to a room. - * @param {string} roomId - * @param {string} userId - * @returns {Promise} `true` on success. - */ - async inviteUser(roomId, userId) { - try { - return !( - await this.api(`/rooms/${roomId}/invite`, "POST", { user_id: userId }) - ).errcode; - } catch (e) { - cerr("invite:", e); - return false; - } - } - - /** - * Performs a moderation action on a user in a room. - * @param {string} action - `"kick"` or `"ban"`. - * @param {string} roomId - * @param {string} userId - * @param {string} [reason=""] - * @returns {Promise} `true` on success. - */ - async #userModAction(action, roomId, userId, reason = "") { - try { - const body = { user_id: userId }; - if (reason) body.reason = reason; - return !(await this.api(`/rooms/${roomId}/${action}`, "POST", body)) - .errcode; - } catch (e) { - cerr(`${action}:`, e); - return false; - } - } - - /** - * Kicks a user from a room. - * @param {string} roomId - * @param {string} userId - * @param {string} [reason=""] - * @returns {Promise} `true` on success. - */ - async kickUser(roomId, userId, reason = "") { - return this.#userModAction("kick", roomId, userId, reason); - } - - /** - * Bans a user from a room. - * @param {string} roomId - * @param {string} userId - * @param {string} [reason=""] - * @returns {Promise} `true` on success. - */ - async banUser(roomId, userId, reason = "") { - return this.#userModAction("ban", roomId, userId, reason); - } - - /** - * Unbans a user from a room. - * @param {string} roomId - * @param {string} userId - * @returns {Promise} `true` on success. - */ - async unbanUser(roomId, userId) { - try { - return !( - await this.api(`/rooms/${roomId}/unban`, "POST", { user_id: userId }) - ).errcode; - } catch (e) { - cerr("unban:", e); - return false; - } - } - - /** - * Fetches the current joined members of a room. - * @param {string} roomId - * @returns {Promise|null>} - */ - async getRoomMembers(roomId) { - try { - const result = await this.api(`/rooms/${roomId}/members`); - if (result.errcode || !result.chunk) return null; - return result.chunk - .filter((e) => e.content?.membership === "join") - .map((e) => ({ - userId: e.state_key, - displayName: - e.content.displayname || e.state_key.split(":")[0].substring(1), - })); - } catch (e) { - cerr("members:", e); - return null; - } - } - - /** - * Sends a room event via a PUT request using a timestamp as transaction ID. - * @param {string} roomId - * @param {string} type - Matrix event type (e.g. `m.room.message`). - * @param {Object} content - Event content. - * @param {string} [errLabel="send"] - Label used in error logs. - * @returns {Promise<{eventId: string}|null>} - */ - async #sendRoomEvent(roomId, type, content, errLabel = "send") { - try { - const result = await this.api( - `/rooms/${roomId}/send/${type}/${Date.now()}`, - "PUT", - content, - ); - return result.errcode ? null : { eventId: result.event_id }; - } catch (e) { - cerr(`${errLabel}:`, e); - return null; - } - } - - /** - * Sends a plain text (or optionally HTML-formatted) message to a room. - * @param {string} roomId - * @param {string} message - Plain text body. - * @param {string|null} [formattedBody=null] - Optional HTML-formatted body. - * @returns {Promise<{eventId: string}|null>} - */ - async sendMessage(roomId, message, formattedBody = null) { - const content = { msgtype: M_TEXT, body: message }; - if (formattedBody) { - content.format = M_HTML; - content.formatted_body = formattedBody; - } - return this.#sendRoomEvent(roomId, M_MSG, content); - } - - /** - * Sends an image message to a room. - * @param {string} roomId - * @param {string} url - An `mxc://` URI for the image. - * @param {string} [body="Image"] - Alt text / fallback body. - * @param {Object} [info={}] - Optional image metadata (e.g. `w`, `h`, `mimetype`). - * @returns {Promise<{eventId: string}|null>} - */ - async sendImage(roomId, url, body = "Image", info = {}) { - const content = { msgtype: M_IMAGE, body, url }; - if (Object.keys(info).length) content.info = info; - return this.#sendRoomEvent(roomId, M_MSG, content, "send image"); - } - - /** - * Edits a previously sent message using the `m.replace` relation. - * @param {string} roomId - * @param {string} eventId - The event ID of the original message. - * @param {string} newMessage - The replacement text body. - * @returns {Promise<{eventId: string}|null>} - */ - async editMessage(roomId, eventId, newMessage) { - return this.#sendRoomEvent( - roomId, - M_MSG, - { - msgtype: M_TEXT, - body: `* ${newMessage}`, - [M_NEWCONT]: { msgtype: M_TEXT, body: newMessage }, - [M_REL]: { rel_type: M_REPLACE, event_id: eventId }, - }, - "edit", - ); - } - - /** - * Redacts (deletes) a room event. - * @param {string} roomId - * @param {string} eventId - * @param {string} [reason=""] - Optional reason for the redaction. - * @returns {Promise<{eventId: string}|null>} - */ - async redactEvent(roomId, eventId, reason = "") { - try { - const result = await this.api( - `/rooms/${roomId}/redact/${eventId}/${Date.now()}`, - "PUT", - reason ? { reason } : {}, - ); - return result.errcode ? null : { eventId: result.event_id }; - } catch (e) { - cerr("redact:", e); - return null; - } - } - - /** - * Sends a reaction annotation to a message. - * @param {string} roomId - * @param {string} eventId - The event to react to. - * @param {string} reaction - The reaction key (typically an emoji). - * @returns {Promise<{eventId: string}|null>} - */ - async reactToMessage(roomId, eventId, reaction) { - return this.#sendRoomEvent( - roomId, - M_REACT, - { [M_REL]: { rel_type: M_ANNOT, event_id: eventId, key: reaction } }, - "react", - ); - } - - /** - * Sends a state event to a room. - * @param {string} roomId - * @param {string} type - Matrix state event type. - * @param {Object} content - Event content. - * @param {string} [stateKey=""] - Optional state key. - * @returns {Promise<{eventId: string}|null>} - */ - async sendStateEvent(roomId, type, content, stateKey = "") { - try { - const result = await this.api( - `/rooms/${roomId}/state/${enc(type)}/${enc(stateKey)}`, - "PUT", - content, - ); - return result.errcode ? null : { eventId: result.event_id }; - } catch (e) { - cerr("state event:", e); - return null; - } - } - - /** - * Sets the name of a room. - * @param {string} roomId - * @param {string} name - * @returns {Promise<{eventId: string}|null>} - */ - async setRoomName(roomId, name) { - return this.sendStateEvent(roomId, M_RNAME, { name }); - } - - /** - * Sets the topic of a room. - * @param {string} roomId - * @param {string} topic - * @returns {Promise<{eventId: string}|null>} - */ - async setRoomTopic(roomId, topic) { - return this.sendStateEvent(roomId, M_RTOPIC, { topic }); - } - - /** - * Sets the avatar for a room. - * @param {string} roomId - * @param {string} url - An `mxc://` URI. - * @returns {Promise<{eventId: string}|null>} - */ - async setRoomAvatar(roomId, url) { - return this.sendStateEvent(roomId, M_RAVATAR, { url }); - } - - /** - * Fetches a specific state event from a room. - * @param {string} roomId - * @param {string} type - Matrix state event type. - * @param {string} [stateKey=""] - * @returns {Promise} The state event content, or `null` on failure. - */ - async getRoomState(roomId, type, stateKey = "") { - try { - const result = await this.api( - `/rooms/${roomId}/state/${enc(type)}/${enc(stateKey)}`, - ); - return result.errcode ? null : result; - } catch (e) { - cerr("get state:", e); - return null; - } - } - - /** - * Gets the name of a room. - * @param {string} roomId - * @returns {Promise} - */ - async getRoomName(roomId) { - return (await this.getRoomState(roomId, M_RNAME))?.name ?? null; - } - - /** - * Gets the topic of a room. - * @param {string} roomId - * @returns {Promise} - */ - async getRoomTopic(roomId) { - return (await this.getRoomState(roomId, M_RTOPIC))?.topic ?? null; - } - - /** - * Fetches a snapshot of common room state (name, topic, avatar, power levels, members). - * @param {string} roomId - * @returns {Promise<{name: string|null, topic: string|null, avatarUrl: string|null, canonicalAlias: string|null, powerLevels: Object|null, members: Array<{userId: string, displayName: string|null, membership: string}>}|null>} - */ - async getRoomAllState(roomId) { - try { - const result = await this.api(`/rooms/${roomId}/state`); - if (!Array.isArray(result)) return null; - const find = (type, key = "") => - result.find((e) => e.type === type && (e.state_key ?? "") === key) - ?.content ?? null; - return { - name: find(M_RNAME)?.name ?? null, - topic: find(M_RTOPIC)?.topic ?? null, - avatarUrl: find(M_RAVATAR)?.url ?? null, - canonicalAlias: find("m.room.canonical_alias")?.alias ?? null, - powerLevels: find("m.room.power_levels"), - members: result - .filter((e) => e.type === M_MEMBER) - .map((e) => ({ - userId: e.state_key, - displayName: e.content?.displayname || null, - membership: e.content?.membership || "leave", - })), - }; - } catch (e) { - cerr("all state:", e); - return null; - } - } - - /** - * Removes a reaction by redacting its event. - * @param {string} roomId - * @param {string} reactionEventId - The event ID of the reaction to remove. - * @returns {Promise} `true` on success. - */ - async removeReaction(roomId, reactionEventId) { - const result = await this.redactEvent(roomId, reactionEventId); - return result !== null; - } - - /** - * Fetches a page of messages from a room's timeline. - * @param {string} roomId - * @param {object} [options] - * @param {string|null} [options.from=null] - Pagination token to start from. - * @param {number} [options.limit=50] - Maximum number of events to return. - * @param {string} [options.dir="b"] - Direction: `"b"` (backwards) or `"f"` (forwards). - * @returns {Promise<{messages: Object[], start: string, end: string}|null>} - */ - async getMessages(roomId, { from = null, limit = 50, dir = "b" } = {}) { - try { - const endpoint = `/rooms/${roomId}/messages?dir=${dir}&limit=${limit}${from ? "&from=" + enc(from) : ""}`; - const result = await this.api(endpoint); - return result.errcode - ? null - : { - messages: result.chunk || [], - start: result.start, - end: result.end, - }; - } catch (e) { - cerr("messages:", e); - return null; - } - } - - /** - * Sends a typing notification to a room. - * @param {string} roomId - * @param {boolean} typing - `true` to indicate typing, `false` to stop. - * @param {number} [timeout=30000] - How long (ms) the typing indicator should remain active. - * @returns {Promise} `true` on success. - */ - async sendTyping(roomId, typing, timeout = 30000) { - try { - return !( - await this.api( - `/rooms/${roomId}/typing/${this.userId}`, - "PUT", - typing ? { typing: true, timeout } : { typing: false }, - ) - ).errcode; - } catch (e) { - cerr("typing:", e); - return false; - } - } - - /** - * Marks an event as read by sending a read receipt. - * @param {string} roomId - * @param {string} eventId - * @returns {Promise} `true` on success. - */ - async sendReadReceipt(roomId, eventId) { - try { - return !( - await this.api( - `/rooms/${roomId}/receipt/m.read/${enc(eventId)}`, - "POST", - {}, - ) - ).errcode; - } catch (e) { - cerr("receipt:", e); - return false; - } - } - - /** - * Uploads binary media to the homeserver's media repository. - * @param {Blob|ArrayBuffer|FormData} data - The media data to upload. - * @param {string} contentType - MIME type (e.g. `"image/png"`). - * @param {string} [filename=""] - Optional filename hint. - * @returns {Promise<{contentUri: string}|null>} The `mxc://` content URI, or `null` on failure. - */ - async uploadMedia(data, contentType, filename = "") { - try { - const qs = filename ? `?filename=${enc(filename)}` : ""; - const headers = { "Content-Type": contentType }; - if (this.accessToken) - headers.Authorization = `Bearer ${this.accessToken}`; - for (const v of ["v3", "r0"]) { - const response = await fetch( - `${this.homeserver}/_matrix/media/${v}/upload${qs}`, - { method: "POST", headers, body: data }, - ); - if (response.status === 404) continue; - const result = await response.json(); - return result.errcode ? null : { contentUri: result.content_uri }; - } - return null; - } catch (e) { - cerr("upload:", e); - return null; - } - } - - /** - * Performs a single `/sync` poll to retrieve new events from the homeserver. - * Pass the returned data to {@link processSyncData} to receive named events. - * @param {string|null} [since=null] - The sync token from a previous sync response. - * @param {number} [timeout=0] - Long-poll timeout in milliseconds. - * @returns {Promise} The raw sync response, or `null` on failure. - */ - async sync(since = null, timeout = 0) { - try { - const result = await this.api( - `/sync?timeout=${timeout}${since ? "&since=" + since : ""}`, - ); - return result.errcode ? null : result; - } catch (e) { - cerr("sync:", e); - return null; - } - } - - /** - * Processes a sync response and emits structured events for new activity. - * Call this with the data returned by {@link sync} after each poll. - * - * Emits: - * - `roomJoin` `{ roomId }` — a room appeared in the sync response for the first time. - * - `roomLeave` `{ roomId }` — the client has left or been removed from a room. - * - `invite` `{ roomId }` — the client received a room invitation. - * - `message` `{ roomId, event }` — a new (non-edit) `m.room.message` event. - * - `edit` `{ roomId, edits, newBody, event }` — a message was edited; `edits` is the event ID of the original message, `newBody` is the new text. - * - `memberUpdate` `{ roomId, change, event }` — a membership change; `change` is the - * object returned by {@link getMembershipChange}. - * - `redaction` `{ roomId, redacts, event }` — an event was redacted (deleted); `redacts` is the event ID that was deleted. - * - `typing` `{ roomId, userIds }` — the current set of typing users in a room changed. - * - * @param {Object} data - The sync response as returned by {@link sync}. - */ - processSyncData(data) { - if (!data) return; - - for (const [roomId, roomData] of Object.entries(data.rooms?.join ?? {})) { - if (!this.#knownRoomIds.has(roomId)) { - this.#knownRoomIds.add(roomId); - this.emit('roomJoin', { roomId }); - } - - for (const event of roomData.timeline?.events ?? []) { - if (event.type === M_MSG && !this.isEditEvent(event)) { - this.emit('message', { roomId, event }); - } - if (event.type === M_MSG && this.isEditEvent(event)) { - const rel = this.getEventRelation(event); - const newBody = this.getEditedBody(event); - this.emit('edit', { roomId, edits: rel.event_id, newBody, event }); - } - if (event.type === M_MEMBER) { - const change = this.getMembershipChange(event); - if (change) this.emit('memberUpdate', { roomId, change, event }); - } - if (event.type === M_REDACTION) { - this.emit('redaction', { roomId, redacts: event.redacts, event }); - } - } - - for (const event of roomData.ephemeral?.events ?? []) { - if (event.type === 'm.typing') { - this.emit('typing', { roomId, userIds: event.content?.user_ids ?? [] }); - } - } - } - - for (const roomId of Object.keys(data.rooms?.leave ?? {})) { - this.#knownRoomIds.delete(roomId); - this.emit('roomLeave', { roomId }); - } - - for (const roomId of Object.keys(data.rooms?.invite ?? {})) { - this.emit('invite', { roomId }); - } - } - - /** - * Registers a listener for a named event. - * @param {string} event - Event name. - * @param {function} fn - Listener callback. - * @returns {this} - */ - on(event, fn) { - if (!this.#handlers.has(event)) this.#handlers.set(event, new Set()); - this.#handlers.get(event).add(fn); - return this; - } - - /** - * Removes a listener (or all listeners) for a named event. - * @param {string} event - Event name. - * @param {function} [fn] - Specific listener to remove. Omit to remove all listeners for the event. - * @returns {this} - */ - off(event, fn) { - if (fn) this.#handlers.get(event)?.delete(fn); - else this.#handlers.delete(event); - return this; - } - - /** - * Emits a named event, invoking all registered listeners with the provided arguments. - * @param {string} event - Event name. - * @param {...*} args - Arguments forwarded to each listener. - */ - emit(event, ...args) { - this.#handlers.get(event)?.forEach((fn) => { - try { - fn(...args); - } catch (e) { - cerr("emit", event, e); - } - }); - } - - /** - * Checks whether a message event mentions a user. - * @param {Object} event - A Matrix room event. - * @param {string} userId - The user ID to check for. - * @returns {boolean} - */ - isMention(event, userId) { - if (!event?.content || !userId) return false; - if (event.type !== M_MSG) return false; - if (event.sender === userId) return false; - const body = event.content.body || ""; - const formattedBody = event.content.formatted_body || ""; - return body.includes(userId) || formattedBody.includes(userId); - } - - /** - * Returns the `m.relates_to` relation object from an event, if present. - * @param {Object} event - A Matrix room event. - * @returns {Object|null} - */ - getEventRelation(event) { - return event?.content?.[M_REL] ?? null; - } - - /** - * Checks whether an event is a message edit (`m.replace` relation). - * @param {Object} event - A Matrix room event. - * @returns {boolean} - */ - isEditEvent(event) { - if (event?.type !== M_MSG) return false; - const rel = this.getEventRelation(event); - return rel?.rel_type === M_REPLACE && !!rel.event_id; - } - - /** - * Checks whether an event is a reaction annotation (`m.annotation`). - * @param {Object} event - A Matrix room event. - * @returns {boolean} - */ - isReactionEvent(event) { - return ( - event?.type === M_REACT && - this.getEventRelation(event)?.rel_type === M_ANNOT - ); - } - - /** - * Extracts the text body from an edited message event. - * Falls back to the regular `body` if no `m.new_content` is present. - * @param {Object} event - A Matrix room event. - * @returns {string|null} - */ - getEditedBody(event) { - if (!event?.content) return null; - return event.content[M_NEWCONT]?.body || event.content.body || null; - } - - /** - * Returns the previous content (`unsigned.prev_content`) of a state event, if present. - * @param {Object} event - A Matrix room event. - * @returns {Object|null} - */ - getPrevContent(event) { - return event?.unsigned?.prev_content ?? null; - } - - /** - * Interprets an `m.room.member` event and returns a structured description of the membership change. - * @param {Object} event - A Matrix `m.room.member` state event. - * @returns {{type: "join"|"rename"|"leave"|"kick"|"ban"|"unknown", userId: string, displayName: string|null, prevDisplayName: string|null, kicker: string|null}|null} - * Returns `null` if the event is not an `m.room.member` type or produces no meaningful change. - */ - getMembershipChange(event) { - if (event?.type !== M_MEMBER) return null; - const userId = event.state_key; - const prevContent = this.getPrevContent(event); - const current = event.content?.membership; - const prev = prevContent?.membership; - const displayName = event.content?.displayname ?? null; - const prevDisplayName = prevContent?.displayname ?? null; - const kicker = event.sender !== userId ? event.sender : null; - - if (current === "join" && prev !== "join") { - return { - type: "join", - userId, - displayName, - prevDisplayName: null, - kicker: null, - }; - } else if (current === "join" && prev === "join") { - if (prevDisplayName && displayName !== prevDisplayName) { - return { - type: "rename", - userId, - displayName, - prevDisplayName, - kicker: null, - }; - } - return null; - } else if (current === "leave" && prev === "join") { - return { - type: kicker ? "kick" : "leave", - userId, - displayName, - prevDisplayName, - kicker, - }; - } else if (current === "ban") { - return { type: "ban", userId, displayName, prevDisplayName, kicker }; - } - return { - type: "unknown", - userId, - displayName, - prevDisplayName, - kicker: null, - }; - } - - /** - * Checks whether an event is an image message. - * @param {Object} event - A Matrix room event. - * @returns {boolean} - */ - isImageMessage(event) { - return event?.type === M_MSG && event.content?.msgtype === M_IMAGE; - } - - /** - * Checks whether a message event contains an HTML-formatted body. - * @param {Object} event - A Matrix room event. - * @returns {boolean} - */ - hasFormattedBody(event) { - return event?.content?.format === M_HTML && !!event.content.formatted_body; - } - - /** - * Extracts the localpart from a Matrix user ID (the segment before the colon). - * @param {string} userId - A Matrix user ID (e.g. `@alice:example.com`). - * @returns {string} The localpart, or `"?"` if extraction fails. - */ - extractLocalpart(userId) { - return userId?.match(/^@([^:]+):/)?.[1] ?? userId ?? "?"; - } - - /** - * Escapes HTML special characters in a plain-text string. - * @param {string} text - * @returns {string} - */ - #escapeHtml(text) { - return text - .replace(/&/g, "&") - .replace(//g, ">"); - } - - /** - * Replaces `@user:server` patterns in plain text with HTML anchor mention links. - * @param {string} text - Plain text possibly containing Matrix user IDs. - * @param {function(string): string} getDisplayName - Callback to resolve a user ID to a display name. - * @returns {string|null} HTML string with mentions linked, or `null` if no mentions were found. - */ - buildMentionHtml(text, getDisplayName) { - const result = text.replace(/@(\S+:\S+)/g, (match, id) => { - const userId = `@${id}`; - const displayName = getDisplayName(userId); - return `@${this.#escapeHtml(displayName)}`; - }); - return result === text ? null : result; - } - - /** - * Sanitizes an HTML string, permitting only a safe subset of tags and converting - * Matrix mention links into `` elements. - * @param {string} html - Raw HTML string to sanitize. - * @returns {string} The sanitized HTML string. - */ - sanitizeHtml(html) { - if (typeof DOMParser === "undefined") return html; - - const doc = new DOMParser().parseFromString(html, "text/html"); - const parts = []; - - const walk = (node) => { - for (const child of [...node.childNodes]) { - if (child.nodeType === Node.TEXT_NODE) { - parts.push(this.#escapeHtml(child.textContent || "")); - } else if (child.nodeType === Node.ELEMENT_NODE) { - const tag = child.tagName.toLowerCase(); - const href = tag === "a" ? (child.getAttribute("href") ?? "") : ""; - - if (tag === "a" && href.startsWith(MATRIX_TO + "@")) { - const userId = decodeURIComponent(href.slice(MATRIX_TO.length)); - parts.push( - '' + - this.#escapeHtml(child.textContent || userId) + - "", - ); - continue; - } - - if (SANITIZE_ALLOWED_TAGS.has(tag)) { - const tagName = tag === "strike" ? "s" : tag; - if (tag === "span" && child.getAttribute("class") === "mention") { - const title = child.getAttribute("title"); - parts.push( - '", - ); - walk(child); - parts.push(""); - } else { - parts.push("<" + tagName + ">"); - walk(child); - parts.push(""); - } - } else { - walk(child); - } - } - } - }; - - walk(doc.body); - return parts.join(""); - } - - /** - * Fetches the most recent text message from a public room using the public read token. - * @param {string} roomAlias - The room alias to look up (e.g. `#room:server`). - * @returns {Promise<{sender: string, body: string, timestamp: number}|null>} - */ - async fetchPublicLastMessage(roomAlias) { - if (!this.publicReadToken) { - console.warn("No public read token"); - return null; - } - try { - const roomId = ( - await this.api( - `/directory/room/${enc(roomAlias)}`, - "GET", - null, - this.publicReadToken, - ) - )?.room_id; - if (!roomId) return null; - const lastEvent = ( - await this.api( - `/rooms/${enc(roomId)}/messages?dir=b&limit=10`, - "GET", - null, - this.publicReadToken, - ) - ).chunk?.find((e) => e?.type === M_MSG && e.content?.body); - return lastEvent - ? { - sender: lastEvent.sender, - body: lastEvent.content.body, - timestamp: lastEvent.origin_server_ts || Date.now(), - } - : null; - } catch (e) { - cerr("public msg:", e); - return null; - } - } - - /** - * Fetches the presence status of a user using the public read token. - * @param {string} userId - * @returns {Promise<{presence: string, lastActive: number}|null>} - */ - async fetchPublicPresence(userId) { - if (!this.publicReadToken) { - console.warn("No public read token"); - return null; - } - try { - const data = await this.api( - `/presence/${enc(userId)}/status`, - "GET", - null, - this.publicReadToken, - ); - return data.errcode - ? null - : { presence: data.presence, lastActive: data.last_active_ago || 0 }; - } catch (e) { - cerr("presence:", e); - return null; - } - } -} - -export default MxjsClient; diff --git a/website/particle/footprint.png b/website/particle/footprint.png deleted file mode 100644 index 8d23f64..0000000 Binary files a/website/particle/footprint.png and /dev/null differ diff --git a/website/particle/particles.png b/website/particle/particles.png deleted file mode 100644 index eec1f89..0000000 Binary files a/website/particle/particles.png and /dev/null differ diff --git a/website/screenborder.png b/website/screenborder.png deleted file mode 100644 index 4c0f5f2..0000000 Binary files a/website/screenborder.png and /dev/null differ diff --git a/website/scripts/ChatAutocomplete.js b/website/scripts/ChatAutocomplete.js new file mode 100644 index 0000000..63f6f8f --- /dev/null +++ b/website/scripts/ChatAutocomplete.js @@ -0,0 +1,183 @@ +import { ChatCommands } from './ChatCommands.js'; + +/** + * Autocomplete popup for slash commands and @mentions. + */ +export class ChatAutocomplete { + #items = []; + #selectedIndex = -1; + #trigger = null; + + /** + * @param {HTMLInputElement} inputEl + * @param {HTMLElement} containerEl + */ + constructor(inputEl, containerEl) { + this.inputEl = inputEl; + + this.el = document.createElement('div'); + this.el.className = 'autocomplete-popup'; + this.el.style.display = 'none'; + containerEl.appendChild(this.el); + + inputEl.addEventListener('input', () => this.#onInput()); + inputEl.addEventListener('keydown', (e) => this.#onKeydown(e)); + inputEl.addEventListener('blur', () => setTimeout(() => this.hide(), 150)); + } + + #getMembersMap = () => new Map(); + + /** + * @param {() => Map} fn + */ + setMembersProvider(fn) { + this.#getMembersMap = fn; + } + + get isVisible() { + return this.el.style.display !== 'none'; + } + + #onInput() { + const val = this.inputEl.value; + const cursor = this.inputEl.selectionStart ?? val.length; + + if (val.startsWith('/') && !val.slice(1).includes(' ')) { + const query = val.slice(1).toLowerCase(); + const matches = ChatCommands.COMMANDS.filter(c => + c.name.startsWith(query) || c.aliases.some(a => a.startsWith(query)) + ); + if (matches.length) { + this.#trigger = { type: 'command', start: 0 }; + this.#show(matches.map(c => ({ + label: `/${c.name}`, + hint: c.args || '', + desc: c.desc, + completion: `/${c.name} `, + }))); + return; + } + } + + const textBeforeCursor = val.slice(0, cursor); + const mentionMatch = textBeforeCursor.match(/(^|\s)@(\S*)$/); + if (mentionMatch) { + const query = mentionMatch[2].toLowerCase(); + const atIndex = textBeforeCursor.lastIndexOf('@'); + const members = [...this.#getMembersMap().entries()] + .filter(([uid, info]) => { + const name = (info.displayName || '').toLowerCase(); + const localpart = uid.match(/^@([^:]+):/)?.[1]?.toLowerCase() ?? ''; + return name.startsWith(query) || localpart.startsWith(query); + }) + .slice(0, 10); + if (members.length) { + this.#trigger = { type: 'mention', start: atIndex }; + this.#show(members.map(([uid, info]) => ({ + label: info.displayName || uid.match(/^@([^:]+):/)?.[1] || uid, + hint: uid, + desc: '', + completion: uid, + }))); + return; + } + } + + this.hide(); + } + + #show(items) { + this.#items = items; + this.#selectedIndex = items.length ? 0 : -1; + this.#render(); + this.el.style.display = 'block'; + } + + #render() { + this.el.innerHTML = ''; + this.#items.forEach((item, i) => { + const row = document.createElement('div'); + row.className = 'ac-item' + (i === this.#selectedIndex ? ' selected' : ''); + + row.appendChild(Object.assign(document.createElement('span'), { + className: 'ac-label', + textContent: item.label, + })); + if (item.hint) { + row.appendChild(Object.assign(document.createElement('span'), { + className: 'ac-hint', + textContent: item.hint, + })); + } + if (item.desc) { + row.appendChild(Object.assign(document.createElement('span'), { + className: 'ac-desc', + textContent: item.desc, + })); + } + + row.addEventListener('mousedown', (e) => { + e.preventDefault(); + this.#apply(i); + }); + this.el.appendChild(row); + }); + } + + #onKeydown(e) { + if (!this.isVisible) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + this.#selectedIndex = (this.#selectedIndex + 1) % this.#items.length; + this.#render(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + this.#selectedIndex = (this.#selectedIndex - 1 + this.#items.length) % this.#items.length; + this.#render(); + } else if (e.key === 'Tab') { + e.preventDefault(); + if (this.#selectedIndex >= 0) this.#apply(this.#selectedIndex); + } else if (e.key === 'Enter') { + if (this.#selectedIndex >= 0) { + e.preventDefault(); + e.stopImmediatePropagation(); + this.#apply(this.#selectedIndex); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + this.hide(); + } + } + + #apply(index) { + const item = this.#items[index]; + if (!item || !this.#trigger) return; + + const val = this.inputEl.value; + const cursor = this.inputEl.selectionStart ?? val.length; + + if (this.#trigger.type === 'command') { + this.inputEl.value = item.completion; + this.inputEl.selectionStart = this.inputEl.selectionEnd = item.completion.length; + } else { + const before = val.slice(0, this.#trigger.start); + const after = val.slice(cursor); + const inserted = item.completion + ' '; + this.inputEl.value = before + inserted + after; + const pos = before.length + inserted.length; + this.inputEl.selectionStart = this.inputEl.selectionEnd = pos; + } + + this.hide(); + this.inputEl.focus(); + this.inputEl.dispatchEvent(new Event('input')); + } + + hide() { + this.#items = []; + this.#selectedIndex = -1; + this.#trigger = null; + this.el.style.display = 'none'; + } +} diff --git a/website/scripts/ChatCommands.js b/website/scripts/ChatCommands.js new file mode 100644 index 0000000..5d35e7d --- /dev/null +++ b/website/scripts/ChatCommands.js @@ -0,0 +1,63 @@ +/** + * Handles IRC-style slash commands for the chat node. + */ +export class ChatCommands { + static COMMANDS = [ + { name: 'nick', args: '', desc: 'Change display name', aliases: [] }, + { name: 'help', args: '', desc: 'Show command list', aliases: [] }, + ]; + + /** + * @param {import('./ChatNode.js').ChatNode} chat + */ + constructor(chat) { + this.chat = chat; + } + + get client() { return this.chat.getClient(); } + + /** + * @param {string} message + */ + async handle(message) { + const parts = message.slice(1).split(' '); + const command = parts[0].toLowerCase(); + const args = parts.slice(1); + + const aliasMap = new Map(); + for (const def of ChatCommands.COMMANDS) { + aliasMap.set(def.name, def.name); + for (const alias of def.aliases) aliasMap.set(alias, def.name); + } + + const canonical = aliasMap.get(command); + if (!canonical || typeof this[canonical] !== 'function') { + this.chat.addErrorMessage(`Unknown command: /${command}. Type /help for commands.`); + return; + } + await this[canonical](args); + } + + async nick(args) { + if (!args[0]) { + this.chat.addErrorMessage('Usage: /nick NewNickname'); + return; + } + try { + if (!await this.client.setDisplayName(args[0])) { + throw new Error('Server refused'); + } + this.chat.addSystemMessage(`Nickname changed to ${args[0]}`); + } catch (e) { + this.chat.addErrorMessage(`Failed to change nickname: ${e.message}`); + } + } + + help() { + [ + '─── Commands ───────────────────────', + 'Profile: /nick ', + '─────────────────────────────────────', + ].forEach(l => this.chat.addSystemMessage(l)); + } +} diff --git a/website/scripts/ChatNode.js b/website/scripts/ChatNode.js new file mode 100644 index 0000000..82ed48b --- /dev/null +++ b/website/scripts/ChatNode.js @@ -0,0 +1,500 @@ +import MxjsClient, { ClientEvents } from 'https://unpkg.com/@litruv/mxjs-lite/dist/mxjs-lite.min.js'; +import { ChatCommands } from './ChatCommands.js'; +import { ChatAutocomplete } from './ChatAutocomplete.js'; + +/** + * Handles chat functionality for a Matrix room using mxjs-lite. + */ +export class ChatNode { + /** @type {MxjsClient | null} */ + #client = null; + + /** @type {string | null} */ + #roomId = null; + + /** @type {HTMLElement | null} */ + #messagesContainer = null; + + /** @type {HTMLInputElement | null} */ + #messageInput = null; + + /** @type {HTMLElement | null} */ + #statusElement = null; + + /** @type {string} */ + #homeserver = ''; + + /** @type {string} */ + #roomAlias = ''; + + /** @type {boolean} */ + #isConnected = false; + + /** @type {Map} */ + #members = new Map(); + + /** @type {Set} */ + #renderedEventIds = new Set(); + + /** @type {boolean} */ + #isLoadingHistory = false; + + /** @type {((username: string, message: string) => void) | null} */ + #onMessageCallback = null; + + /** @type {ChatCommands | null} */ + #commands = null; + + /** @type {ChatAutocomplete | null} */ + #autocomplete = null; + + /** @type {string} */ + #storageKey = ''; + + /** @type {string | (() => string)} */ + #guestName = ''; + + /** + * @param {string} homeserver Matrix homeserver URL + * @param {string} roomAlias Room alias to join (e.g., #general:matrix.org) + */ + constructor(homeserver, roomAlias) { + this.#homeserver = homeserver; + this.#roomAlias = roomAlias; + this.#storageKey = `mxjs_chat_${homeserver}_${roomAlias}`; + } + + /** + * Get the Matrix client instance for external use + * @returns {MxjsClient | null} + */ + getClient() { + return this.#client; + } + + /** + * Set callback for message events + * @param {(username: string, message: string) => void} callback + */ + setOnMessage(callback) { + this.#onMessageCallback = callback; + } + + /** + * Get members map for autocomplete + * @returns {Map} + */ + getMembers() { + return this.#members; + } + + /** + * Adds a system message (public for commands) + * @param {string} text + */ + addSystemMessage(text) { + this.#addSystemMessage(text); + } + + /** + * Adds an error message (public for commands) + * @param {string} text + */ + addErrorMessage(text) { + this.#addErrorMessage(text); + } + + /** + * Initializes the chat node with DOM elements. + * + * @param {HTMLElement} messagesContainer Container for messages + * @param {HTMLInputElement} messageInput Input field for messages + * @param {HTMLElement} statusElement Status indicator element + * @param {HTMLElement} inputContainer Input container for autocomplete + * @param {string} homeserver Homeserver URL (can be empty if provided via connection) + * @param {string} roomAlias Room alias (can be empty if provided via connection) + * @param {string | (() => string)} guestName Guest display name or function to resolve it + */ + initialize(messagesContainer, messageInput, statusElement, inputContainer, homeserver, roomAlias, guestName = '') { + this.#messagesContainer = messagesContainer; + this.#messageInput = messageInput; + this.#statusElement = statusElement; + this.#guestName = guestName; + + // Use provided values or fallback to constructor values + if (homeserver) this.#homeserver = homeserver; + if (roomAlias) this.#roomAlias = roomAlias; + + // Setup autocomplete + this.#autocomplete = new ChatAutocomplete(messageInput, inputContainer); + this.#autocomplete.setMembersProvider(() => this.getMembers()); + + // Setup commands + this.#commands = new ChatCommands(this); + + messageInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter' && !this.#autocomplete.isVisible) this.#sendMessage(); + }); + + this.#updateStatus('disconnected', 'Not connected'); + } + + /** + * Try to auto-connect with saved credentials + */ + async #tryAutoConnect() { + try { + const saved = localStorage.getItem(this.#storageKey); + if (!saved) return; + + const { accessToken, userId, deviceId } = JSON.parse(saved); + if (!accessToken || !userId) return; + + this.#updateStatus('connecting', 'Auto-connecting...'); + + this.#client = new MxjsClient({ homeserver: this.#homeserver }); + this.#client.accessToken = accessToken; + this.#client.userId = userId; + if (deviceId) this.#client.deviceId = deviceId; + + const joinResult = await this.#client.joinRoom(this.#roomAlias); + if (!joinResult) throw new Error('Failed to join room'); + + this.#roomId = joinResult.roomId; + this.#bindEvents(); + + await this.#client.startSync(30000); + this.#isConnected = true; + this.#updateStatus('connected', 'Connected'); + + this.#addSystemMessage('Connected to chat'); + } catch (error) { + console.error('Auto-connect failed:', error); + localStorage.removeItem(this.#storageKey); + this.#client = null; + } + } + + /** + * Connects to the Matrix homeserver as a guest. + */ + async #connect() { + try { + this.#updateStatus('connecting', 'Connecting...'); + + this.#client = new MxjsClient({ homeserver: this.#homeserver }); + + const authResult = await this.#client.registerGuest(); + if (!authResult) throw new Error('Guest registration failed'); + + // Save credentials + localStorage.setItem(this.#storageKey, JSON.stringify({ + accessToken: authResult.accessToken, + userId: authResult.userId, + deviceId: authResult.deviceId || null + })); + + // Set display name if provided + const nameToSet = typeof this.#guestName === 'function' ? this.#guestName() : this.#guestName; + if (nameToSet?.trim()) { + console.log('[ChatNode] Setting display name:', nameToSet); + try { + await this.#client.setDisplayName(nameToSet.trim()); + console.log('[ChatNode] Display name set successfully'); + } catch (e) { + console.warn('Failed to set display name:', e); + } + } else { + console.log('[ChatNode] No guest name provided, skipping setDisplayName'); + } + + const joinResult = await this.#client.joinRoom(this.#roomAlias); + if (!joinResult) throw new Error('Failed to join room'); + + this.#roomId = joinResult.roomId; + this.#bindEvents(); + + await this.#client.startSync(30000); + this.#isConnected = true; + this.#updateStatus('connected', 'Connected'); + + this.#addSystemMessage('Connected to chat'); + } catch (error) { + console.error(' Chat connection error:', error); + this.#updateStatus('error', 'Connection failed'); + this.#addErrorMessage(`Connection failed: ${error.message}`); + } + } + + /** + * Reconnects to chat (disconnect and connect again) + */ + async #reconnect() { + this.disconnect(); + this.#renderedEventIds.clear(); + if (this.#messagesContainer) this.#messagesContainer.innerHTML = ''; + await this.#connect(); + } + + /** + * Binds Matrix client events. + */ + #bindEvents() { + const c = this.#client; + if (!c) return; + + c.on(ClientEvents.Ready, () => { + this.#loadHistory(); + }); + + c.on(ClientEvents.MessageCreate, ({ roomId, event }) => { + if (roomId !== this.#roomId) return; + // Don't trigger callbacks for messages during history load + this.#renderMessage(event, this.#isLoadingHistory); + }); + + c.on(ClientEvents.MessageUpdate, ({ roomId, edits, newBody }) => { + if (roomId !== this.#roomId || !this.#messagesContainer) return; + const msgEl = this.#messagesContainer.querySelector(`[data-event-id="${edits}"]`); + if (!msgEl) return; + + const contentEl = msgEl.querySelector('.chat-msg-content'); + if (contentEl) contentEl.textContent = newBody; + + if (!msgEl.querySelector('.chat-msg-edited')) { + msgEl.appendChild(Object.assign(document.createElement('span'), { + className: 'chat-msg-edited', + textContent: '(edited)' + })); + } + }); + + c.on(ClientEvents.MessageDelete, ({ roomId, redacts }) => { + if (roomId !== this.#roomId || !this.#messagesContainer) return; + const msgEl = this.#messagesContainer.querySelector(`[data-event-id="${redacts}"]`); + if (msgEl) msgEl.classList.add('chat-msg-deleted'); + }); + + c.on(ClientEvents.MemberUpdate, ({ roomId, change }) => { + if (roomId !== this.#roomId) return; + if (change.type === 'join' || change.type === 'rename') { + this.#members.set(change.userId, { + displayName: change.displayName || this.#extractLocalpart(change.userId), + avatarUrl: change.avatarUrl || null + }); + } else if (change.type === 'leave' || change.type === 'kick' || change.type === 'ban') { + this.#members.delete(change.userId); + } + }); + } + + /** + * Loads recent message history. + */ + async #loadHistory() { + if (!this.#client || !this.#roomId) return; + + this.#isLoadingHistory = true; + try { + const members = await this.#client.getJoinedMembers(this.#roomId); + if (members?.members) { + for (const m of members.members) { + this.#members.set(m.userId, { + displayName: m.displayName || this.#extractLocalpart(m.userId), + avatarUrl: m.avatarUrl || null + }); + } + } + + const history = await this.#client.getMessages(this.#roomId, { limit: 20 }); + if (!history?.messages) return; + + const messages = [...history.messages].reverse(); + for (const event of messages) { + if (event.event_id && this.#renderedEventIds.has(event.event_id)) continue; + if (event.type === 'm.room.message' && event.content?.body) { + this.#renderMessage(event, true); + } + } + } catch (error) { + console.error('Failed to load history:', error); + } finally { + this.#isLoadingHistory = false; + } + } + + /** + * Renders a message event. + * + * @param {Object} event Matrix message event + * @param {boolean} isHistorical Whether this is a historical message (don't trigger callback) + */ + #renderMessage(event, isHistorical = false) { + if (!this.#messagesContainer || !event.content?.body) return; + if (event.event_id) { + if (this.#renderedEventIds.has(event.event_id)) return; + this.#renderedEventIds.add(event.event_id); + } + + const member = this.#members.get(event.sender); + const displayName = member?.displayName || this.#extractLocalpart(event.sender); + const timestamp = event.origin_server_ts || Date.now(); + const isSelf = event.sender === this.#client?.userId; + + // Trigger onMessage callback only for new messages (not historical) + if (this.#onMessageCallback && !isSelf && !isHistorical) { + this.#onMessageCallback(displayName, event.content.body); + } + + const msgEl = document.createElement('div'); + msgEl.className = `chat-msg ${isSelf ? 'chat-msg-self' : ''}`; + if (event.event_id) msgEl.dataset.eventId = event.event_id; + + const timeEl = document.createElement('span'); + timeEl.className = 'chat-msg-time'; + timeEl.textContent = this.#formatTime(timestamp); + + const senderEl = document.createElement('span'); + senderEl.className = 'chat-msg-sender'; + senderEl.textContent = displayName; + + const contentEl = document.createElement('span'); + contentEl.className = 'chat-msg-content'; + contentEl.textContent = event.content.body; + + msgEl.append(timeEl, senderEl, contentEl); + this.#messagesContainer.appendChild(msgEl); + this.#messagesContainer.scrollTop = this.#messagesContainer.scrollHeight; + } + + /** + * Adds a system message. + * + * @param {string} text Message text + */ + #addSystemMessage(text) { + if (!this.#messagesContainer) return; + + const msgEl = document.createElement('div'); + msgEl.className = 'chat-msg-system'; + msgEl.textContent = `*** ${text}`; + + this.#messagesContainer.appendChild(msgEl); + this.#messagesContainer.scrollTop = this.#messagesContainer.scrollHeight; + } + + /** + * Adds an error message. + * + * @param {string} text Message text + */ + #addErrorMessage(text) { + if (!this.#messagesContainer) return; + + const msgEl = document.createElement('div'); + msgEl.className = 'chat-msg-error'; + msgEl.textContent = `*** ERROR: ${text}`; + + this.#messagesContainer.appendChild(msgEl); + this.#messagesContainer.scrollTop = this.#messagesContainer.scrollHeight; + } + + /** + * Sends a message to the room. + */ + async #sendMessage() { + if (!this.#client || !this.#roomId || !this.#messageInput || !this.#isConnected) return; + + const message = this.#messageInput.value.trim(); + if (!message) return; + + this.#messageInput.disabled = true; + + try { + // Handle commands + if (message.startsWith('/')) { + this.#messageInput.value = ''; + await this.#commands?.handle(message); + return; + } + + const result = await this.#client.sendMessage(this.#roomId, message); + if (!result?.eventId) throw new Error('Failed to send message'); + this.#messageInput.value = ''; + } catch (error) { + console.error('Failed to send message:', error); + this.#addErrorMessage(`Failed to send: ${error.message}`); + } finally { + this.#messageInput.disabled = false; + this.#messageInput.focus(); + } + } + + /** + * Formats a timestamp. + * + * @param {number} ts Timestamp in milliseconds + * @returns {string} Formatted time + */ + #formatTime(ts) { + const d = new Date(ts); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + } + + /** + * Extracts the localpart from a Matrix user ID. + * + * @param {string} userId Matrix user ID + * @returns {string} Localpart + */ + #extractLocalpart(userId) { + const match = userId.match(/^@([^:]+):/); + return match ? match[1] : userId; + } + + /** + * Updates the status indicator. + * + * @param {string} state State (connecting, connected, error, disconnected) + * @param {string} text Status text + */ + #updateStatus(state, text) { + if (!this.#statusElement) return; + this.#statusElement.className = `chat-status chat-status-${state}`; + this.#statusElement.textContent = text; + } + + /** + * Public method to trigger connection. + */ + connect() { + if (this.#client && this.#client.isReady()) { + console.log('[ChatNode] Already connected'); + return; + } + this.#connect(); + } + + /** + * Checks if the chat is connected. + * + * @returns {boolean} + */ + isConnected() { + return this.#client?.isReady() ?? false; + } + + /** + * Disconnects from the chat. + */ + disconnect() { + if (this.#client) { + this.#client.stopSync(); + this.#client = null; + } + this.#isConnected = false; + this.#roomId = null; + this.#members.clear(); + this.#updateStatus('disconnected', 'Disconnected'); + } +} diff --git a/website/scripts/ConnectionRenderer.js b/website/scripts/ConnectionRenderer.js new file mode 100644 index 0000000..db62123 --- /dev/null +++ b/website/scripts/ConnectionRenderer.js @@ -0,0 +1,218 @@ +import { getTypeColor } from "./getTypeColor.js"; + +/** + * @typedef {import('./GraphConnection.js').GraphConnection} GraphConnection + * @typedef {import('./NodeRenderer.js').NodeRenderer} NodeRenderer + * @typedef {import('./WorkspaceNavigator.js').WorkspaceNavigator} WorkspaceNavigator + */ + +/** + * Renders bezier SVG connections between node pins. + * Matches the geometry logic of BlueprintWorkspace.#renderConnections / #computeConnectionGeometry. + */ +export class ConnectionRenderer { + /** @type {SVGElement} */ + #svg; + + /** @type {HTMLElement} */ + #canvas; + + /** @type {NodeRenderer} */ + #nodeRenderer; + + /** @type {WorkspaceNavigator} */ + #nav; + + /** @type {Map} */ + #paths = new Map(); + + /** @type {Set} */ + #highlightedPaths = new Set(); + + /** + * @param {SVGElement} svg The connection layer SVG element (world-space, inside worldLayer). + * @param {HTMLElement} canvas The workspace canvas element (used for bounding rect). + * @param {NodeRenderer} nodeRenderer + * @param {WorkspaceNavigator} nav + */ + constructor(svg, canvas, nodeRenderer, nav) { + this.#svg = svg; + this.#canvas = canvas; + this.#nodeRenderer = nodeRenderer; + this.#nav = nav; + } + + /** + * Redraws all connections. Call after DOM layout settles (e.g. rAF). + */ + render() { + const canvasRect = this.#canvas.getBoundingClientRect(); + const cw = canvasRect.width; + const ch = canvasRect.height; + const zoom = this.#nav.zoomLevel; + + const connections = this.#nodeRenderer.getConnections(); + const activeIds = new Set(); + + connections.forEach(conn => { + activeIds.add(conn.id); + + let path = this.#paths.get(conn.id); + if (!path) { + path = this.#createPath(conn.kind); + this.#svg.appendChild(path); + this.#paths.set(conn.id, path); + } + + const geometry = this.#computeGeometry(conn, canvasRect); + if (!geometry) { + path.setAttribute("d", ""); + return; + } + + const { start, end } = geometry; + const cpOffset = Math.max(60 * zoom, Math.abs(end.x - start.x) * 0.5); + + // Viewport cull: AABB of all 4 bezier control points vs canvas + const MARGIN = 100 * zoom; + const minX = Math.min(start.x, start.x + cpOffset, end.x - cpOffset, end.x); + const maxX = Math.max(start.x, start.x + cpOffset, end.x - cpOffset, end.x); + const minY = Math.min(start.y, end.y); + const maxY = Math.max(start.y, end.y); + if (maxX < -MARGIN || minX > cw + MARGIN || maxY < -MARGIN || minY > ch + MARGIN) { + path.setAttribute("d", ""); + return; + } + + path.setAttribute("d", + `M ${start.x} ${start.y} C ${start.x + cpOffset} ${start.y} ${end.x - cpOffset} ${end.y} ${end.x} ${end.y}` + ); + path.style.stroke = getTypeColor(conn.kind); + }); + + // Remove stale paths + this.#paths.forEach((path, id) => { + if (!activeIds.has(id)) { + path.remove(); + this.#paths.delete(id); + } + }); + } + + // ─── Public — highlighting ───────────────────────────────────────────────── + + /** + * Highlights all SVG paths connected to the given pin. + * + * @param {string} nodeId + * @param {string} pinId + */ + highlightPin(nodeId, pinId) { + this.clearHighlight(); + for (const conn of this.#nodeRenderer.getConnections()) { + const matches = + (conn.from.nodeId === nodeId && conn.from.pinId === pinId) || + (conn.to.nodeId === nodeId && conn.to.pinId === pinId); + if (!matches) continue; + const path = this.#paths.get(conn.id); + if (path) { + path.dataset.highlighted = "true"; + this.#highlightedPaths.add(path); + } + } + } + + /** + * Removes highlight from all previously highlighted paths. + */ + clearHighlight() { + this.#highlightedPaths.forEach(p => delete p.dataset.highlighted); + this.#highlightedPaths.clear(); + } + + /** + * Animates a glowing pulse sweeping along a clone of the path, leaving the original untouched. + * Resolves once the animation finishes. + * + * @param {string} connId + * @param {number} [durationMs] + * @returns {Promise} + */ + async activatePath(connId, durationMs = 200) { + const path = this.#paths.get(connId); + if (!path) { + await new Promise(r => window.setTimeout(r, durationMs)); + return; + } + + const len = path.getTotalLength(); + if (!len) { + await new Promise(r => window.setTimeout(r, durationMs)); + return; + } + + // Clone the path so the original is never mutated. + const ghost = /** @type {SVGPathElement} */ (path.cloneNode(false)); + const pulse = Math.min(100, Math.max(30, len * 0.35)); + ghost.style.strokeDasharray = `${pulse} ${len + pulse}`; + ghost.style.strokeDashoffset = `${pulse}`; + ghost.style.pointerEvents = "none"; + ghost.removeAttribute("data-highlighted"); + ghost.removeAttribute("data-executing"); + path.insertAdjacentElement("afterend", ghost); + + const anim = ghost.animate( + [ + { strokeDashoffset: pulse, strokeWidth: "7", filter: "brightness(5) drop-shadow(0 0 14px currentColor)" }, + { strokeDashoffset: -(len * 0.5), strokeWidth: "5", filter: "brightness(3) drop-shadow(0 0 8px currentColor)", offset: 0.5 }, + { strokeDashoffset: -(len + pulse), strokeWidth: "2.5", filter: "brightness(1) drop-shadow(0 0 0px currentColor)" }, + ], + { duration: durationMs, easing: "ease-in", fill: "forwards" } + ); + + await anim.finished; + ghost.remove(); + } + + // ─── Private ────────────────────────────────────────────────────────────── + + /** + * @param {string} kind + * @returns {SVGPathElement} + */ + #createPath(kind) { + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("fill", "none"); + path.setAttribute("stroke-width", "2.5"); + path.setAttribute("stroke-linecap", "round"); + path.dataset.kind = kind; + return path; + } + + /** + * Returns screen-space coordinates for a connection's pin handles. + * + * @param {GraphConnection} conn + * @param {DOMRect} canvasRect + * @returns {{ start: {x:number,y:number}, end: {x:number,y:number} } | null} + */ + #computeGeometry(conn, canvasRect) { + const fromHandle = this.#nodeRenderer.getPinHandle(conn.from.nodeId, conn.from.pinId, "output"); + const toHandle = this.#nodeRenderer.getPinHandle(conn.to.nodeId, conn.to.pinId, "input"); + if (!fromHandle || !toHandle) return null; + + const fromRect = fromHandle.getBoundingClientRect(); + const toRect = toHandle.getBoundingClientRect(); + + return { + start: { + x: fromRect.left - canvasRect.left + fromRect.width / 2, + y: fromRect.top - canvasRect.top + fromRect.height / 2, + }, + end: { + x: toRect.left - canvasRect.left + toRect.width / 2, + y: toRect.top - canvasRect.top + toRect.height / 2, + }, + }; + } +} diff --git a/website/scripts/DebugPanel.js b/website/scripts/DebugPanel.js new file mode 100644 index 0000000..c433bf3 --- /dev/null +++ b/website/scripts/DebugPanel.js @@ -0,0 +1,479 @@ +/** + * Debug overlay panel — top-right corner. + * Displays live viewport info and exposes the draggable-items toggle. + */ +export class DebugPanel { + /** @type {HTMLElement} */ + #panel; + + /** @type {HTMLElement} */ + #zoomEl; + + /** @type {HTMLElement} */ + #dprEl; + + /** @type {HTMLElement | null} */ + #viewportEl; + + /** @type {HTMLElement} */ + #centerEl; + + /** @type {HTMLElement} */ + #viewboxEl; + + /** @type {HTMLElement} */ + #anchorEl; + + /** @type {HTMLInputElement} */ + #draggableCheckbox; + + /** @type {HTMLElement} */ + #activeItemRow; + + /** @type {HTMLElement} */ + #activeItemLabel; + + /** @type {HTMLElement} */ + #activeItemPos; + + /** @type {HTMLButtonElement} */ + #copyBtn; + + /** @type {HTMLButtonElement} */ + #copyJsonBtn; + + /** @type {HTMLButtonElement} */ + #showPropertiesBtn; + + /** @type {HTMLElement} */ + #nodeEditorDivider; + + /** @type {HTMLElement} */ + #nodeEditor; + + /** @type {HTMLInputElement} */ + #nodeIdInput; + + /** @type {HTMLInputElement} */ + #nodeTypeInput; + + /** @type {HTMLInputElement} */ + #nodeTitleInput; + + /** @type {HTMLInputElement} */ + #nodeXInput; + + /** @type {HTMLInputElement} */ + #nodeYInput; + + /** @type {HTMLInputElement} */ + #nodeWidthInput; + + /** @type {HTMLElement} */ + #inputsList; + + /** @type {HTMLElement} */ + #outputsList; + + /** @type {HTMLButtonElement} */ + #addInputBtn; + + /** @type {HTMLButtonElement} */ + #addOutputBtn; + + /** @type {HTMLInputElement} */ + #worldboxCheckbox; + + /** @type {HTMLInputElement} */ + #viewboxCheckbox; + + /** @type {((enabled: boolean) => void) | null} */ + #onDraggableChange = null; + + /** @type {((enabled: boolean) => void) | null} */ + #onWorldBoxChange = null; + + /** @type {((enabled: boolean) => void) | null} */ + #onViewboxChange = null; + + /** @type {(() => void) | null} */ + #onCopyNode = null; + + /** @type {(() => void) | null} */ + #onCopyGraph = null; + + /** @type {(() => void) | null} */ + #onShowProperties = null; + + /** @type {((nodeId: string, updates: any) => void) | null} */ + #onNodeUpdate = null; + + /** @type {string | null} */ + #lastActiveId = null; + + /** + * @param {HTMLElement} panel - The `.debug-panel` element. + * @param {((enabled: boolean) => void) | null} [onDraggableChange] + * @param {(() => void) | null} [onCopyNode] + * @param {((nodeId: string, updates: any) => void) | null} [onNodeUpdate] + * @param {((enabled: boolean) => void) | null} [onWorldBoxChange] + * @param {(() => void) | null} [onCopyGraph] + * @param {((enabled: boolean) => void) | null} [onViewboxChange] + * @param {(() => void) | null} [onShowProperties] + */ + constructor(panel, onDraggableChange = null, onCopyNode = null, onNodeUpdate = null, onWorldBoxChange = null, onCopyGraph = null, onViewboxChange = null, onShowProperties = null) { + this.#panel = panel; + this.#onDraggableChange = onDraggableChange; + this.#onCopyNode = onCopyNode; + this.#onCopyGraph = onCopyGraph; + this.#onNodeUpdate = onNodeUpdate; + this.#onWorldBoxChange = onWorldBoxChange; + this.#onViewboxChange = onViewboxChange; + this.#onShowProperties = onShowProperties; + this.#zoomEl = /** @type {HTMLElement} */ (panel.querySelector(".debug-zoom")); + this.#dprEl = /** @type {HTMLElement} */ (panel.querySelector(".debug-dpr")); + this.#viewportEl = /** @type {HTMLElement} */ (panel.querySelector(".debug-viewport")); + this.#centerEl = /** @type {HTMLElement} */ (panel.querySelector(".debug-center")); + this.#viewboxEl = /** @type {HTMLElement} */ (panel.querySelector(".debug-viewbox")); + this.#anchorEl = /** @type {HTMLElement} */ (panel.querySelector(".debug-anchor")); + this.#draggableCheckbox = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-draggable-cb")); + this.#activeItemRow = /** @type {HTMLElement} */ (panel.querySelector(".debug-active-row")); + this.#activeItemLabel = /** @type {HTMLElement} */ (panel.querySelector(".debug-active-label")); + this.#activeItemPos = /** @type {HTMLElement} */ (panel.querySelector(".debug-active-pos")); + this.#copyBtn = /** @type {HTMLButtonElement} */ (panel.querySelector(".debug-copy-btn")); + this.#copyJsonBtn = /** @type {HTMLButtonElement} */ (panel.querySelector(".debug-copy-json-btn")); + this.#showPropertiesBtn = /** @type {HTMLButtonElement} */ (panel.querySelector(".debug-show-properties-btn")); + this.#nodeEditorDivider = /** @type {HTMLElement} */ (document.getElementById("debugNodeEditorDivider")); + this.#nodeEditor = /** @type {HTMLElement} */ (document.getElementById("debugNodeEditor")); + this.#nodeIdInput = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-node-id")); + this.#nodeTypeInput = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-node-type")); + this.#nodeTitleInput = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-node-title")); + this.#nodeXInput = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-node-x")); + this.#nodeYInput = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-node-y")); + this.#nodeWidthInput = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-node-width")); + this.#inputsList = /** @type {HTMLElement} */ (document.getElementById("debugInputsList")); + this.#outputsList = /** @type {HTMLElement} */ (document.getElementById("debugOutputsList")); + this.#addInputBtn = /** @type {HTMLButtonElement} */ (panel.querySelector(".debug-add-input-btn")); + this.#addOutputBtn = /** @type {HTMLButtonElement} */ (panel.querySelector(".debug-add-output-btn")); + this.#worldboxCheckbox = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-worldbox-cb")); + this.#viewboxCheckbox = /** @type {HTMLInputElement} */ (panel.querySelector(".debug-viewbox-cb")); + + const toggleBtn = /** @type {HTMLButtonElement} */ (panel.querySelector(".debug-toggle-btn")); + toggleBtn?.addEventListener("click", () => { + panel.classList.toggle("collapsed"); + }); + + this.#draggableCheckbox.addEventListener("change", () => { + this.#onDraggableChange?.(this.#draggableCheckbox.checked); + }); + + this.#worldboxCheckbox?.addEventListener("change", () => { + this.#onWorldBoxChange?.(this.#worldboxCheckbox.checked); + }); + + this.#viewboxCheckbox?.addEventListener("change", () => { + this.#onViewboxChange?.(this.#viewboxCheckbox.checked); + }); + + this.#copyBtn.addEventListener("click", () => { + this.#onCopyNode?.(); + }); + + this.#copyJsonBtn?.addEventListener("click", () => { + this.#onCopyGraph?.(); + }); + + this.#showPropertiesBtn?.addEventListener("click", () => { + this.#onShowProperties?.(); + }); + + this.#nodeTitleInput.addEventListener("input", () => this.#handleNodeChange()); + this.#nodeXInput.addEventListener("input", () => this.#handleNodeChange()); + this.#nodeYInput.addEventListener("input", () => this.#handleNodeChange()); + this.#nodeWidthInput.addEventListener("input", () => this.#handleNodeChange()); + + this.#addInputBtn.addEventListener("click", () => this.#handleAddPin("input")); + this.#addOutputBtn.addEventListener("click", () => this.#handleAddPin("output")); + } + + // ─── Public ─────────────────────────────────────────────────────────────── + + /** @returns {string | null} */ + get lastActiveId() { return this.#lastActiveId; } + + /** + * Sets the draggable checkbox to the given value without firing the change callback. + * + * @param {boolean} enabled + */ + setDraggable(enabled) { + this.#draggableCheckbox.checked = enabled; + } + + /** + * Updates all displayed values. + * + * @param {{ zoom: number, center: { x: number, y: number }, viewbox: { x: number, y: number, width: number, height: number } }} viewInfo + * @param {string | null} [activeId] + * @param {{ x: number, y: number, width: number, height: number } | null} [activeRect] + * @param {any} [nodeData] - Full node data object for editor + */ + update(viewInfo, activeId = null, activeRect = null, nodeData = null) { + const { zoom, center, viewbox } = viewInfo; + + this.#zoomEl.textContent = zoom.toFixed(3); + this.#dprEl.textContent = (window.devicePixelRatio || 1).toFixed(2); + + // Use document.documentElement if window dimensions are 0 + const vpWidth = window.innerWidth || document.documentElement.clientWidth || 0; + const vpHeight = window.innerHeight || document.documentElement.clientHeight || 0; + if (this.#viewportEl) { + this.#viewportEl.textContent = `${vpWidth} × ${vpHeight}`; + } + + this.#centerEl.textContent = `${this.#fmt(center.x)}, ${this.#fmt(center.y)}`; + this.#viewboxEl.textContent = + `${this.#fmt(viewbox.x)}, ${this.#fmt(viewbox.y)} ${this.#fmt(viewbox.width)} \u00d7 ${this.#fmt(viewbox.height)}`; + + if (activeRect && viewbox.width > 0 && viewbox.height > 0) { + // 0 = node left/top edge at viewport left/top, 1 = node right/bottom edge at viewport right/bottom. + const ax = (activeRect.x - viewbox.x) / Math.max(1, viewbox.width - activeRect.width); + const ay = (activeRect.y - viewbox.y) / Math.max(1, viewbox.height - activeRect.height); + this.#anchorEl.textContent = `${ax.toFixed(2)}, ${ay.toFixed(2)}`; + } else { + this.#anchorEl.textContent = '\u2014'; + } + + if (activeId && activeRect) { + this.#lastActiveId = activeId; + this.#activeItemRow.hidden = false; + this.#activeItemLabel.textContent = activeId; + this.#activeItemPos.textContent = + `${this.#fmt(activeRect.x)}, ${this.#fmt(activeRect.y)}`; + + if (nodeData) { + this.#populateNodeEditor(nodeData); + } + } else { + this.#activeItemRow.hidden = true; + this.#hideNodeEditor(); + } + } + + /** + * Populates node editor with node data. + * + * @param {any} node + */ + #populateNodeEditor(node) { + this.#nodeEditorDivider.hidden = false; + this.#nodeEditor.hidden = false; + + this.#nodeIdInput.value = node.id || ''; + this.#nodeTypeInput.value = node.type || ''; + this.#nodeTitleInput.value = node.title || ''; + this.#nodeXInput.value = String(node.position?.x ?? 0); + this.#nodeYInput.value = String(node.position?.y ?? 0); + this.#nodeWidthInput.value = node.width != null ? String(node.width) : ''; + + this.#renderPinsList(node.inputs || [], "input"); + this.#renderPinsList(node.outputs || [], "output"); + } + + /** + * Renders pin list. + * + * @param {any[]} pins + * @param {('input'|'output')} direction + */ + #renderPinsList(pins, direction) { + const container = direction === "input" ? this.#inputsList : this.#outputsList; + container.innerHTML = ''; + + pins.forEach((pin, index) => { + const pinEl = document.createElement("div"); + pinEl.className = "debug-pin-item"; + pinEl.innerHTML = ` +
+ + + + +
+
+ +
+ `; + + pinEl.querySelectorAll('.debug-pin-input').forEach(input => { + input.addEventListener('input', () => this.#handlePinChange(direction)); + input.addEventListener('change', () => this.#handlePinChange(direction)); + }); + + const removeBtn = pinEl.querySelector('.debug-pin-remove'); + removeBtn?.addEventListener('click', () => this.#handleRemovePin(direction, index)); + + container.appendChild(pinEl); + }); + } + + /** + * Hides node editor. + */ + #hideNodeEditor() { + this.#nodeEditorDivider.hidden = true; + this.#nodeEditor.hidden = true; + } + + /** + * Handles node property changes. + */ + #handleNodeChange() { + if (!this.#lastActiveId || !this.#onNodeUpdate) return; + + const updates = { + title: this.#nodeTitleInput.value, + position: { + x: parseFloat(this.#nodeXInput.value) || 0, + y: parseFloat(this.#nodeYInput.value) || 0 + } + }; + + const widthVal = this.#nodeWidthInput.value.trim(); + if (widthVal !== '') { + updates.width = parseFloat(widthVal) || null; + } + + this.#onNodeUpdate(this.#lastActiveId, updates); + } + + /** + * Handles pin property changes. + * + * @param {('input'|'output')} direction + */ + #handlePinChange(direction) { + if (!this.#lastActiveId || !this.#onNodeUpdate) return; + + const container = direction === "input" ? this.#inputsList : this.#outputsList; + const pins = []; + + container.querySelectorAll('.debug-pin-item').forEach(pinEl => { + const idInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-id')); + const nameInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-name')); + const kindSelect = /** @type {HTMLSelectElement} */ (pinEl.querySelector('.debug-pin-kind')); + const defaultInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-default')); + + const pin = { + id: idInput.value || `pin_${Date.now()}`, + name: nameInput.value || '', + kind: kindSelect.value, + direction: direction + }; + + if (defaultInput.value.trim() !== '') { + pin.defaultValue = defaultInput.value; + } + + pins.push(pin); + }); + + this.#onNodeUpdate(this.#lastActiveId, { [direction === "input" ? "inputs" : "outputs"]: pins }); + } + + /** + * Handles adding a new pin. + * + * @param {('input'|'output')} direction + */ + #handleAddPin(direction) { + if (!this.#lastActiveId || !this.#onNodeUpdate) return; + + const container = direction === "input" ? this.#inputsList : this.#outputsList; + const newPin = { + id: `pin_${Date.now()}`, + name: '', + kind: 'exec', + direction: direction + }; + + // Get existing pins and add new one + const pins = []; + container.querySelectorAll('.debug-pin-item').forEach(pinEl => { + const idInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-id')); + const nameInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-name')); + const kindSelect = /** @type {HTMLSelectElement} */ (pinEl.querySelector('.debug-pin-kind')); + const defaultInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-default')); + + const pin = { + id: idInput.value, + name: nameInput.value, + kind: kindSelect.value, + direction: direction + }; + + if (defaultInput.value.trim() !== '') { + pin.defaultValue = defaultInput.value; + } + + pins.push(pin); + }); + + pins.push(newPin); + this.#onNodeUpdate(this.#lastActiveId, { [direction === "input" ? "inputs" : "outputs"]: pins }); + } + + /** + * Handles removing a pin. + * + * @param {('input'|'output')} direction + * @param {number} index + */ + #handleRemovePin(direction, index) { + if (!this.#lastActiveId || !this.#onNodeUpdate) return; + + const container = direction === "input" ? this.#inputsList : this.#outputsList; + const pins = []; + + container.querySelectorAll('.debug-pin-item').forEach((pinEl, i) => { + if (i === index) return; // Skip removed pin + + const idInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-id')); + const nameInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-name')); + const kindSelect = /** @type {HTMLSelectElement} */ (pinEl.querySelector('.debug-pin-kind')); + const defaultInput = /** @type {HTMLInputElement} */ (pinEl.querySelector('.debug-pin-default')); + + const pin = { + id: idInput.value, + name: nameInput.value, + kind: kindSelect.value, + direction: direction + }; + + if (defaultInput.value.trim() !== '') { + pin.defaultValue = defaultInput.value; + } + + pins.push(pin); + }); + + this.#onNodeUpdate(this.#lastActiveId, { [direction === "input" ? "inputs" : "outputs"]: pins }); + } + + // ─── Private ────────────────────────────────────────────────────────────── + + /** + * Formats a world-space number to a compact fixed-point string. + * + * @param {number} n + * @returns {string} + */ + #fmt(n) { + return n.toFixed(1); + } +} diff --git a/website/scripts/GlobalVariables.js b/website/scripts/GlobalVariables.js new file mode 100644 index 0000000..85dd16c --- /dev/null +++ b/website/scripts/GlobalVariables.js @@ -0,0 +1,62 @@ +/** + * Simple global variable registry for sharing values between nodes. + */ +export class GlobalVariables { + /** @type {Map} */ + static #variables = new Map(); + + /** + * Sets a global variable. + * + * @param {string} name Variable name + * @param {any} value Variable value + */ + static set(name, value) { + this.#variables.set(name, value); + } + + /** + * Gets a global variable. + * + * @param {string} name Variable name + * @returns {any} Variable value or undefined + */ + static get(name) { + return this.#variables.get(name); + } + + /** + * Checks if a variable exists. + * + * @param {string} name Variable name + * @returns {boolean} + */ + static has(name) { + return this.#variables.has(name); + } + + /** + * Deletes a variable. + * + * @param {string} name Variable name + */ + static delete(name) { + this.#variables.delete(name); + } + + /** + * Clears all variables. + */ + static clear() { + this.#variables.clear(); + } + + /** + * Gets all variable names. + * + * @returns {string[]} + */ + static keys() { + return Array.from(this.#variables.keys()); + } +} diff --git a/website/scripts/GraphComment.js b/website/scripts/GraphComment.js new file mode 100644 index 0000000..111dc7b --- /dev/null +++ b/website/scripts/GraphComment.js @@ -0,0 +1,19 @@ +/** + * Represents a comment box for annotating/grouping nodes. + * Similar to Unreal Engine blueprint comments. + */ +export class GraphComment { + /** + * @param {{ id: string, title: string, position: {x:number,y:number}, size: {width:number,height:number}, color?: string, opacity?: number }} init + */ + constructor(init) { + this.id = init.id; + this.title = init.title; + this.position = { ...init.position }; + this.size = { ...init.size }; + /** @type {string} */ + this.color = init.color || "#4a5568"; + /** @type {number} */ + this.opacity = init.opacity ?? 0.15; + } +} diff --git a/website/scripts/GraphConnection.js b/website/scripts/GraphConnection.js new file mode 100644 index 0000000..3df222d --- /dev/null +++ b/website/scripts/GraphConnection.js @@ -0,0 +1,21 @@ +/** + * @typedef {{ nodeId: string, pinId: string }} PinRef + */ + +/** + * Represents a directional connection between two node pins. + */ +export class GraphConnection { + /** + * @param {PinRef} from Output pin reference. + * @param {PinRef} to Input pin reference. + * @param {string} kind Pin type kind. + * @param {string} [id] + */ + constructor(from, to, kind, id) { + this.id = id ?? (globalThis.crypto?.randomUUID?.() ?? `conn_${Math.random().toString(36).slice(2, 10)}`); + this.from = { ...from }; + this.to = { ...to }; + this.kind = kind; + } +} diff --git a/website/scripts/GraphExecutor.js b/website/scripts/GraphExecutor.js new file mode 100644 index 0000000..a15b32a --- /dev/null +++ b/website/scripts/GraphExecutor.js @@ -0,0 +1,148 @@ +/** + * @typedef {import('./NodeRenderer.js').NodeRenderer} NodeRenderer + * @typedef {(nodeId: string, value: string) => void} PrintHandler + * @typedef {(connId: string, kind: string) => Promise} StepHandler + */ + +import { GlobalVariables } from './GlobalVariables.js'; +import { NodeRegistry, NodeExecutionContext } from './nodes/index.js'; + +/** + * Walks exec connections from a starting output pin and runs node logic + * for recognised node types (print, etc.). + * + * Execution is fully async — each exec hop awaits the optional onStep callback, + * allowing callers to animate wires between steps. + * + * Resolution order for string values: + * 1. The defaultValue on the output pin of the connected source node. + * 2. The defaultValue on the input pin itself (unconnected fallback). + */ +export class GraphExecutor { + /** @type {NodeRenderer} */ + #nodeRenderer; + + /** @type {PrintHandler | null} */ + #onPrint; + + /** + * Called with (connId, kind) before traversing each connection. + * Awaited for exec wires; fire-and-forget for data wires. + * + * @type {StepHandler | null} + */ + #onStep; + + /** + * @param {NodeRenderer} nodeRenderer + * @param {PrintHandler | null} [onPrint] - Invoked when a print node executes. + * @param {StepHandler | null} [onStep] - Awaited when traversing exec connections. + */ + constructor(nodeRenderer, onPrint = null, onStep = null) { + this.#nodeRenderer = nodeRenderer; + this.#onPrint = onPrint; + this.#onStep = onStep; + } + + // ─── Public ─────────────────────────────────────────────────────────────── + + /** + * Starts async execution from a node's exec output pin. + * + * @param {string} nodeId + * @param {string} [pinId] + * @returns {Promise} + */ + execute(nodeId, pinId = "exec_out") { + return this.#runExecPin(nodeId, pinId); + } + + // ─── Private ────────────────────────────────────────────────────────────── + + /** + * Follows a single exec output connection, animates it, then executes the target node. + * + * @param {string} nodeId + * @param {string} pinId + * @returns {Promise} + */ + async #runExecPin(nodeId, pinId) { + const conn = this.#nodeRenderer.getConnections() + .find(c => c.from.nodeId === nodeId && c.from.pinId === pinId); + if (!conn) return; + if (this.#onStep) await this.#onStep(conn.id, conn.kind); + await this.#executeNode(conn.to.nodeId); + } + + /** + * Executes the logic for a single node then advances through exec_out. + * + * @param {string} nodeId + * @returns {Promise} + */ + async #executeNode(nodeId) { + const node = this.#nodeRenderer.getNodes().find(n => n.id === nodeId); + if (!node) return; + + const ctx = new NodeExecutionContext( + nodeId, + this.#nodeRenderer, + (nId, pId) => this.#resolveInput(nId, pId), + (nId, pId) => this.#runExecPin(nId, pId), + this.#onPrint, + this.#onStep + ); + + const handler = NodeRegistry.BlueprintPure_Get(node.type); + if (handler) { + try { + await handler.BlueprintCallable_Execute(ctx); + } catch (error) { + console.error(`[Node ${nodeId}] Execution error:`, error); + } + } + + // Always continue exec flow + await this.#runExecPin(nodeId, "exec_out"); + } + + /** + * Resolves an input value by following connections and handling special node types. + * + * @param {string} nodeId + * @param {string} pinId + * @returns {any} + */ + #resolveInput(nodeId, pinId) { + const conn = this.#nodeRenderer.getConnections().find( + c => c.to.nodeId === nodeId && c.to.pinId === pinId + ); + + if (conn) { + // Auto-flash data wire when a value is resolved (fire-and-forget) + this.#onStep?.(conn.id, conn.kind); + const sourceNode = this.#nodeRenderer.getNodes().find(n => n.id === conn.from.nodeId); + const sourcePin = sourceNode?.getPin(conn.from.pinId); + + if (sourceNode?.type === "get_matrix_chat" && conn.from.pinId === "value") { + if (GlobalVariables.has("MatrixChat")) return GlobalVariables.get("MatrixChat"); + } + + if (sourceNode?.type === "random_name" && conn.from.pinId === "name") { + return sourceNode._generatedName || ""; + } + + if (sourceNode?.type === "append" && conn.from.pinId === "result") { + const input1 = this.#resolveInput(sourceNode.id, "input1"); + const input2 = this.#resolveInput(sourceNode.id, "input2"); + const input3 = this.#resolveInput(sourceNode.id, "input3"); + return input1 + input2 + input3; + } + + if (sourcePin?.defaultValue != null) return sourcePin.defaultValue; + } + + const node = this.#nodeRenderer.getNodes().find(n => n.id === nodeId); + return node?.getPin(pinId)?.defaultValue ?? ""; + } +} diff --git a/website/scripts/GraphNode.js b/website/scripts/GraphNode.js new file mode 100644 index 0000000..6471269 --- /dev/null +++ b/website/scripts/GraphNode.js @@ -0,0 +1,45 @@ +/** + * @typedef {Object} PinDescriptor + * @property {string} id Unique pin identifier scoped to the node. + * @property {string} name Display name. + * @property {('input'|'output')} direction Pin direction. + * @property {('exec'|'number'|'boolean'|'string'|'table'|'any'|'color')} kind Pin type kind. + * @property {string} [defaultValue] Optional default value displayed inline for string/number-kind pins. + * @property {number} [min] Optional minimum value for number pins. + * @property {number} [max] Optional maximum value for number pins. + */ + +/** + * Represents a blueprint node instance. + */ +export class GraphNode { + /** + * @param {{ id: string, type: string, title: string, position: {x:number,y:number}, width?: number, inputs: PinDescriptor[], outputs: PinDescriptor[], markdownSrc?: string, imageSrc?: string, lottieSrc?: string, focusOptions?: { paddingFraction?: number, durationMs?: number, minWorldBox?: { width: number, height: number }, responsiveWorldBox?: { minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number } | Array<{ minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number }>, anchorX?: number, anchorY?: number } }} init + */ + constructor(init) { + this.id = init.id; + this.type = init.type; + this.title = init.title; + this.position = { ...init.position }; + /** @type {number | undefined} */ + this.width = init.width; + this.inputs = init.inputs.map(p => ({ ...p })); + this.outputs = init.outputs.map(p => ({ ...p })); + /** @type {string | undefined} */ + this.markdownSrc = init.markdownSrc; + /** @type {string | undefined} */ + this.imageSrc = init.imageSrc; + /** @type {string | undefined} */ + this.lottieSrc = init.lottieSrc; + /** @type {{ paddingFraction?: number, durationMs?: number, minWorldBox?: { width: number, height: number }, anchorX?: number, anchorY?: number } | undefined} */ + this.focusOptions = init.focusOptions; + } + + /** + * @param {string} pinId + * @returns {PinDescriptor | undefined} + */ + getPin(pinId) { + return [...this.inputs, ...this.outputs].find(p => p.id === pinId); + } +} diff --git a/website/scripts/ItemDragger.js b/website/scripts/ItemDragger.js new file mode 100644 index 0000000..4800240 --- /dev/null +++ b/website/scripts/ItemDragger.js @@ -0,0 +1,698 @@ +/** + * @typedef {import('./WorkspaceNavigator.js').WorkspaceNavigator} WorkspaceNavigator + * @typedef {import('./NodeRenderer.js').NodeRenderer} NodeRenderer + */ + +/** + * Handles pointer-driven drag movement and sway animation for world-space items. + * + * Ported directly from Picograph's WorkspaceDragManager + WorkspaceGeometry: + * - Anchor-ratio drag (cursor grabs exactly where it hits the element) + * - Ghost placeholder for nodes while dragging + * - movementX-preferred velocity → rotation blend (±7° clamp, grab-point pivot) + * - 0.85×/frame rotation decay RAF loop + * - RAF-coalesced connection refresh during drag, immediate flush on drop + * - Grid snapping on ghost preview and drop (dragging remains smooth) + */ +export class ItemDragger { + /** Grid size for snapping (matches CSS --grid-size) */ + static GRID_SIZE = 20; + /** @type {HTMLElement} */ + #canvas; + + /** @type {WorkspaceNavigator} */ + #nav; + + /** @type {NodeRenderer} */ + #nodeRenderer; + + /** @type {boolean} */ + #enabled = false; + + /** @type {{ id: string, el: HTMLElement, pos: { x: number, y: number } }[]} */ + #images = []; + + /** + * Per-item apply-transform callbacks. + * + * @type {Map void>} + */ + #applyTransform = new Map(); + + /** @type {Map} */ + #itemPositions = new Map(); + + // ─── Rotation (matches workspace.nodeDragRotation in Picograph) ──────────── + + /** @type {Map} */ + #dragRotation = new Map(); + + /** + * Stores the grab-ratio pivot point per item, used to set transform-origin during rotation. + * + * @type {Map} + */ + #dragOrigin = new Map(); + + /** @type {number | null} */ + #decayFrame = null; + + // ─── Ghost (node drag placeholder) ──────────────────────────────────────── + + /** @type {{ el: HTMLElement, nodeId: string, width: number, height: number } | null} */ + #ghost = null; + + // ─── Connection refresh coalescing ───────────────────────────────────────── + + /** @type {number | null} */ + #connRefreshFrame = null; + + // ─── Edge-pan (auto-scroll while dragging near canvas edges) ────────────── + + /** + * @type {{ clientX: number, clientY: number, + * anchors: Map, + * primaryId: string, useGhost: boolean } | null} + */ + #edgePanState = null; + + /** @type {number | null} */ + #edgePanFrame = null; + + /** @type {boolean} */ + #dragging = false; + + /** @type {(() => void) | null} */ + #cancelActiveDrag = null; + + // ─── Active item (debug panel) ───────────────────────────────────────────── + + /** @type {string | null} */ + #activeId = null; + + /** @type {{ x: number, y: number } | null} */ + #activePos = null; + + /** @type {((id: string | null, pos: { x: number, y: number } | null) => void) | null} */ + #onActiveItemChange = null; + + /** @type {(() => void) | null} */ + #onItemMoved = null; + + /** + * @param {HTMLElement} canvas - The workspace canvas element. + * @param {WorkspaceNavigator} nav + * @param {NodeRenderer} nodeRenderer + * @param {((id: string | null, pos: { x: number, y: number } | null) => void) | null} [onActiveItemChange] + * @param {(() => void) | null} [onItemMoved] + */ + constructor(canvas, nav, nodeRenderer, onActiveItemChange = null, onItemMoved = null) { + this.#canvas = canvas; + this.#nav = nav; + this.#nodeRenderer = nodeRenderer; + this.#onActiveItemChange = onActiveItemChange; + this.#onItemMoved = onItemMoved; + } + + // ─── Public ─────────────────────────────────────────────────────────────── + + /** @returns {string | null} */ + get activeId() { return this.#activeId; } + + /** @returns {{ x: number, y: number } | null} */ + get activePos() { return this.#activePos; } + + /** True while a drag gesture is actively in progress. */ + get isDragging() { return this.#dragging; } + + /** + * Immediately drops any active drag, snapping the item to grid at its current position. + * Call this when a pinch-to-zoom starts so the two gestures don't fight each other. + */ + cancelDrag() { + this.#cancelActiveDrag?.(); + } + + /** @param {boolean} v */ + set enabled(v) { + this.#enabled = v; + this.#images.forEach(({ el }) => { + el.style.pointerEvents = v ? "auto" : "none"; + el.style.cursor = v ? "grab" : ""; + }); + this.#nodeRenderer.getNodeElements().forEach((el) => { + el.dataset.draggable = v ? "true" : "false"; + }); + if (!v) { + this.#removeGhost(); + this.#stopDecay(); + } + } + + /** @returns {boolean} */ + get enabled() { return this.#enabled; } + + /** + * Registers an image element as draggable. + * + * @param {string} id + * @param {HTMLElement} el + * @param {{ x: number, y: number }} initialPos + */ + registerImage(id, el, initialPos) { + const entry = { id, el, pos: { ...initialPos } }; + this.#images.push(entry); + this.#itemPositions.set(id, { ...initialPos }); + this.#applyTransform.set(id, (x, y, rot) => { + entry.pos.x = x; + entry.pos.y = y; + el.style.transform = this.#buildTransform(x, y, rot); + }); + + el.addEventListener("pointerdown", (e) => { + if (!this.#enabled) return; + e.stopPropagation(); + const pointerWorld = this.#clientToWorkspace(e.clientX, e.clientY); + const zoom = this.#nav.zoomLevel || 1; + const rect = el.getBoundingClientRect(); + const width = rect.width / zoom || 512; + const height = rect.height / zoom || 512; + const ratioX = width > 0 ? (pointerWorld.x - entry.pos.x) / width : 0; + const ratioY = height > 0 ? (pointerWorld.y - entry.pos.y) / height : 0; + this.#beginDrag(id, e, el, new Map([[id, { ratioX, ratioY, width, height }]]), false); + }); + } + + /** + * Registers a node element as draggable. Call after the node has been added to NodeRenderer. + * + * @param {string} nodeId + */ + registerNode(nodeId) { + const el = this.#nodeRenderer.getNodeElements().get(nodeId); + const node = this.#nodeRenderer.getNodes().find(n => n.id === nodeId); + if (!el || !node) return; + + this.#itemPositions.set(nodeId, { ...node.position }); + this.#applyTransform.set(nodeId, (x, y, rot) => { + this.#nodeRenderer.setNodeTransform(nodeId, x, y, rot); + }); + + el.addEventListener("pointerdown", (e) => { + if (!this.#enabled) return; + const target = /** @type {HTMLElement} */ (e.target); + if (target.closest(".pin-handle")) return; + e.stopPropagation(); + + const pointerWorld = this.#clientToWorkspace(e.clientX, e.clientY); + const zoom = this.#nav.zoomLevel || 1; + const rect = el.getBoundingClientRect(); + const width = Math.max(1, rect.width / zoom); + const height = Math.max(1, rect.height / zoom); + const ratioX = width > 0 ? (pointerWorld.x - node.position.x) / width : 0; + const ratioY = height > 0 ? (pointerWorld.y - node.position.y) / height : 0; + this.#beginDrag(nodeId, e, el, new Map([[nodeId, { ratioX, ratioY, width, height }]]), true); + }); + } + + // ─── Drag ───────────────────────────────────────────────────────────────── + + /** + * @param {string} primaryId + * @param {PointerEvent} event + * @param {HTMLElement} primaryEl + * @param {Map} anchors + * @param {boolean} useGhost + */ + #beginDrag(primaryId, event, primaryEl, anchors, useGhost) { + // Don't start a drag while a pinch-to-zoom is already running. + // This can happen when both fingers land simultaneously: touchstart (pinch) + // fires before pointerdown, so the guard here catches the race. + if (this.#nav.isPinching) return; + anchors.forEach((_, id) => this.#dragRotation.delete(id)); + + // Stop any previous edge-pan and mark drag as active + this.#stopEdgePan(); + this.#dragging = true; + + // Store grab-point ratios and apply them as transform-origin for pivot rotation + anchors.forEach((anchor, id) => { + this.#dragOrigin.set(id, { ratioX: anchor.ratioX, ratioY: anchor.ratioY }); + const el = this.#getItemElement(id); + if (el) el.style.transformOrigin = `${anchor.ratioX * 100}% ${anchor.ratioY * 100}%`; + }); + + primaryEl.style.transition = ""; + + let prevClientX = event.clientX; + let prevClientY = event.clientY; + let prevMoveTime = performance.now(); + + // Ghost placeholder (nodes only) + if (useGhost) { + const anchor = anchors.get(primaryId); + if (anchor) { + const initialPointer = this.#clientToWorkspace(event.clientX, event.clientY); + const ghostPos = { + x: initialPointer.x - anchor.ratioX * anchor.width, + y: initialPointer.y - anchor.ratioY * anchor.height, + }; + this.#ensureGhost(primaryId, primaryEl, anchor.width, anchor.height, ghostPos); + } + } + + this.#setActive(primaryId, this.#itemPositions.get(primaryId) ?? null); + + // Initialise edge-pan state so #tickEdgePan can reference anchors without closure mutation + this.#edgePanState = { clientX: event.clientX, clientY: event.clientY, anchors, primaryId, useGhost }; + + const handlePointerMove = (/** @type {PointerEvent} */ moveE) => { + if (moveE.pointerId !== event.pointerId) return; + + const pointer = this.#clientToWorkspace(moveE.clientX, moveE.clientY); + + anchors.forEach((anchor, id) => { + const newX = pointer.x - anchor.ratioX * anchor.width; + const newY = pointer.y - anchor.ratioY * anchor.height; + this.#itemPositions.set(id, { x: newX, y: newY }); + const rot = this.#dragRotation.get(id) ?? 0; + this.#applyTransform.get(id)?.(newX, newY, rot); + + if (useGhost && id === primaryId) { + this.#updateGhost(newX, newY); + } + }); + + this.#setActive(primaryId, this.#itemPositions.get(primaryId) ?? null); + + // Update edge-pan pointer so the RAF loop knows where the pointer is + this.#edgePanState.clientX = moveE.clientX; + this.#edgePanState.clientY = moveE.clientY; + if (this.#edgePanFrame === null) { + this.#edgePanFrame = requestAnimationFrame(() => this.#tickEdgePan()); + } + + // Compute time-normalised velocity so touch (coalesced, less frequent events) and + // mouse (per-frame events) produce the same rotation magnitude at the same finger/cursor speed. + // Target frame budget: 16 ms. Clamp dt to 8–60 ms to avoid spikes on tab-switch etc. + const now = performance.now(); + const dt = Math.max(8, Math.min(60, now - prevMoveTime)); + prevMoveTime = now; + const rawDelta = moveE.clientX - prevClientX; + const velocityX = (rawDelta / dt) * 16; + anchors.forEach((_, id) => this.#nudgeRotation(id, velocityX)); + + prevClientX = moveE.clientX; + prevClientY = moveE.clientY; + + this.#scheduleConnectionRefresh(); + }; + + const handlePointerFinish = (/** @type {PointerEvent} */ finishE) => { + if (finishE.pointerId !== event.pointerId) return; + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerFinish); + window.removeEventListener("pointercancel", handlePointerFinish); + + this.#cancelActiveDrag = null; + this.#stopEdgePan(); + this.#dragging = false; + + // Commit position from final pointer coords, snapped to grid + if (Number.isFinite(finishE.clientX)) { + const pointer = this.#clientToWorkspace(finishE.clientX, finishE.clientY); + anchors.forEach((anchor, id) => { + const rawX = pointer.x - anchor.ratioX * anchor.width; + const rawY = pointer.y - anchor.ratioY * anchor.height; + const snapped = this.#snapToGrid(rawX, rawY); + this.#itemPositions.set(id, { x: snapped.x, y: snapped.y }); + }); + } + + // Zero out rotations with snap-back transition, then decay + anchors.forEach((_, id) => this.#setDragRotation(id, 0)); + + if (useGhost) { + const pos = this.#itemPositions.get(primaryId); + if (pos) this.#updateGhost(pos.x, pos.y); + window.setTimeout(() => this.#removeGhost(), 160); + } + + // Keep selection active after drag ends + this.#flushConnectionRefresh(); + }; + + // Exposed so external callers (e.g. pinch-to-zoom start) can drop the drag cleanly + this.#cancelActiveDrag = () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerFinish); + window.removeEventListener("pointercancel", handlePointerFinish); + + this.#cancelActiveDrag = null; + this.#stopEdgePan(); + this.#dragging = false; + + // Snap current live positions to grid + anchors.forEach((_, id) => { + const pos = this.#itemPositions.get(id); + if (!pos) return; + const snapped = this.#snapToGrid(pos.x, pos.y); + this.#itemPositions.set(id, snapped); + const rot = this.#dragRotation.get(id) ?? 0; + this.#applyTransform.get(id)?.(snapped.x, snapped.y, rot); + }); + + anchors.forEach((_, id) => this.#setDragRotation(id, 0)); + + if (useGhost) { + const pos = this.#itemPositions.get(primaryId); + if (pos) this.#updateGhost(pos.x, pos.y); + window.setTimeout(() => this.#removeGhost(), 160); + } + + this.#flushConnectionRefresh(); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerFinish); + window.addEventListener("pointercancel", handlePointerFinish); + } + + // ─── Ghost ──────────────────────────────────────────────────────────────── + + /** + * @param {string} nodeId + * @param {HTMLElement} nodeEl + * @param {number} width + * @param {number} height + * @param {{ x: number, y: number }} pos + */ + #ensureGhost(nodeId, nodeEl, width, height, pos) { + this.#removeGhost(); + const el = document.createElement("div"); + el.className = "node-drag-ghost"; + el.style.width = `${width}px`; + el.style.height = `${height}px`; + el.style.transform = `translate3d(${pos.x}px, ${pos.y}px, 0)`; + el.setAttribute("role", "presentation"); + if (nodeEl.parentElement) { + nodeEl.parentElement.insertBefore(el, nodeEl); + } else { + nodeEl.appendChild(el); + } + this.#ghost = { el, nodeId, width, height }; + } + + /** + * @param {number} x + * @param {number} y + */ + #updateGhost(x, y) { + if (!this.#ghost) return; + const snapped = this.#snapToGrid(x, y); + this.#ghost.el.style.transform = `translate3d(${snapped.x}px, ${snapped.y}px, 0)`; + } + + #removeGhost() { + if (!this.#ghost) return; + if (this.#ghost.el.isConnected) this.#ghost.el.remove(); + this.#ghost = null; + } + + // ─── Edge-pan (auto-scroll while dragging near canvas edges) ───────────── + + /** + * Stops the edge-pan RAF loop and clears its state. + */ + #stopEdgePan() { + if (this.#edgePanFrame !== null) { + cancelAnimationFrame(this.#edgePanFrame); + this.#edgePanFrame = null; + } + this.#edgePanState = null; + } + + /** + * RAF loop that scrolls the viewport when the drag pointer is within 10% of any canvas edge, + * then re-positions the dragged item so it stays under the pointer. + * Runs continuously while the pointer stays in the edge zone; stops when it leaves. + */ + #tickEdgePan() { + this.#edgePanFrame = null; + const state = this.#edgePanState; + if (!state) return; + + const rect = this.#canvas.getBoundingClientRect(); + const { clientX, clientY, anchors, primaryId, useGhost } = state; + + const edgeW = rect.width * 0.10; + const edgeH = rect.height * 0.10; + /** Max pan speed in screen pixels per frame */ + const MAX_SPEED = 14; + + const cx = clientX - rect.left; + const cy = clientY - rect.top; + + let screenDx = 0; + let screenDy = 0; + + if (cx < edgeW) screenDx = MAX_SPEED * (1 - cx / edgeW); + if (cx > rect.width - edgeW) screenDx = -MAX_SPEED * (1 - (rect.width - cx) / edgeW); + if (cy < edgeH) screenDy = MAX_SPEED * (1 - cy / edgeH); + if (cy > rect.height - edgeH) screenDy = -MAX_SPEED * (1 - (rect.height - cy) / edgeH); + + if (screenDx !== 0 || screenDy !== 0) { + const zoom = this.#nav.zoomLevel || 1; + this.#nav.panBy(screenDx / zoom, screenDy / zoom); + + // Re-derive world position from the (now-updated) viewport so node follows pointer + const pointer = this.#clientToWorkspace(clientX, clientY); + anchors.forEach((anchor, id) => { + const newX = pointer.x - anchor.ratioX * anchor.width; + const newY = pointer.y - anchor.ratioY * anchor.height; + this.#itemPositions.set(id, { x: newX, y: newY }); + const rot = this.#dragRotation.get(id) ?? 0; + this.#applyTransform.get(id)?.(newX, newY, rot); + if (useGhost && id === primaryId) { + this.#updateGhost(newX, newY); + } + }); + + this.#scheduleConnectionRefresh(); + this.#edgePanFrame = requestAnimationFrame(() => this.#tickEdgePan()); + } + // Pointer outside the edge zone — loop pauses; next pointermove will restart it if needed + } + + // ─── Rotation ───────────────────────────────────────────────────────────── + + /** + * Blends horizontal velocity into an item's rotation state. + * Exact port of nudgeNodeDragRotation from WorkspaceDragManager. + * + * @param {string} id + * @param {number} velocityX + */ + #nudgeRotation(id, velocityX) { + if (!Number.isFinite(velocityX)) return; + const existing = this.#dragRotation.get(id) ?? 0; + const blended = existing * 0.92 + velocityX * 0.18; + this.#setDragRotation(id, blended); + } + + /** + * Applies or clears a rotation and updates the item's transform. + * Exact port of setNodeDragRotation from WorkspaceDragManager. + * + * @param {string} id + * @param {number} angle + */ + #setDragRotation(id, angle) { + const sanitized = Number.isFinite(angle) ? angle : 0; + const clamped = Math.max(-7, Math.min(7, sanitized)); + if (Math.abs(clamped) < 0.01) { + this.#dragRotation.delete(id); + this.#dragOrigin.delete(id); + const el = this.#getItemElement(id); + if (el) el.style.transformOrigin = ""; + } else { + this.#dragRotation.set(id, clamped); + } + + const pos = this.#itemPositions.get(id); + if (pos) { + this.#applyTransform.get(id)?.(pos.x, pos.y, clamped); + } + + if (this.#dragRotation.size > 0) { + this.#ensureDecay(); + } else { + this.#stopDecay(); + } + } + + /** + * Returns the DOM element for a registered item (image or node). + * + * @param {string} id + * @returns {HTMLElement | null} + */ + #getItemElement(id) { + const img = this.#images.find(i => i.id === id); + if (img) return img.el; + return this.#nodeRenderer.getNodeElements().get(id) ?? null; + } + + // ─── Decay ──────────────────────────────────────────────────────────────── + + /** + * Ensures the rotation decay RAF loop is running. + * Exact port of ensureNodeRotationDecay from WorkspaceDragManager. + */ + #ensureDecay() { + if (this.#decayFrame !== null) return; + + const step = () => { + let hasActive = false; + this.#dragRotation.forEach((angle, id) => { + const pos = this.#itemPositions.get(id); + if (!pos) { this.#dragRotation.delete(id); return; } + + const damped = angle * 0.85; + if (Math.abs(damped) < 0.05) { + this.#dragRotation.delete(id); + this.#dragOrigin.delete(id); + const el = this.#getItemElement(id); + if (el) el.style.transformOrigin = ""; + this.#applyTransform.get(id)?.(pos.x, pos.y, 0); + } else { + this.#dragRotation.set(id, damped); + this.#applyTransform.get(id)?.(pos.x, pos.y, damped); + hasActive = true; + } + }); + + this.#scheduleConnectionRefresh(); + + if (hasActive) { + this.#decayFrame = requestAnimationFrame(step); + } else { + this.#decayFrame = null; + this.#flushConnectionRefresh(); + } + }; + + this.#decayFrame = requestAnimationFrame(step); + } + + /** + * Stops the decay loop if running. + * Exact port of stopNodeRotationDecay from WorkspaceDragManager. + */ + #stopDecay() { + if (this.#decayFrame === null) return; + cancelAnimationFrame(this.#decayFrame); + this.#decayFrame = null; + } + + // ─── Connection refresh coalescing ──────────────────────────────────────── + + /** + * Queues a single RAF to refresh connections. + * Matches scheduleConnectionRefresh in BlueprintWorkspace. + */ + #scheduleConnectionRefresh() { + if (this.#connRefreshFrame !== null) return; + this.#connRefreshFrame = requestAnimationFrame(() => { + this.#connRefreshFrame = null; + this.#onItemMoved?.(); + }); + } + + /** + * Immediately refreshes connections, cancelling any pending RAF. + * Matches flushConnectionRefresh in BlueprintWorkspace. + */ + #flushConnectionRefresh() { + if (this.#connRefreshFrame !== null) { + cancelAnimationFrame(this.#connRefreshFrame); + this.#connRefreshFrame = null; + } + this.#onItemMoved?.(); + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + /** + * Snaps a world-space position to the grid. + * + * @param {number} x + * @param {number} y + * @returns {{ x: number, y: number }} + */ + #snapToGrid(x, y) { + const gridSize = ItemDragger.GRID_SIZE; + return { + x: Math.round(x / gridSize) * gridSize, + y: Math.round(y / gridSize) * gridSize, + }; + } + + /** + * Converts client coordinates to world space. + * Exact port of clientToWorkspace from WorkspaceDragManager. + * + * @param {number} clientX + * @param {number} clientY + * @returns {{ x: number, y: number }} + */ + #clientToWorkspace(clientX, clientY) { + const rect = this.#canvas.getBoundingClientRect(); + const zoom = Math.max(0.01, this.#nav.zoomLevel || 1); + const offset = this.#nav.getEffectiveOffset(); + return { + x: (clientX - rect.left) / zoom - offset.x, + y: (clientY - rect.top) / zoom - offset.y, + }; + } + + /** + * Builds a CSS transform matching WorkspaceGeometry.positionToTransform in Picograph. + * + * @param {number} x + * @param {number} y + * @param {number} rotation + * @returns {string} + */ + #buildTransform(x, y, rotation) { + const rotZ = Number.isFinite(rotation) ? rotation : 0; + if (!rotZ) return `translate3d(${x}px, ${y}px, 0)`; + return `translate3d(${x}px, ${y}px, 0) rotate(${rotZ}deg)`; + } + + /** + * @param {string | null} id + * @param {{ x: number, y: number } | null} pos + */ + #setActive(id, pos) { + this.#activeId = id; + this.#activePos = pos; + this.#onActiveItemChange?.(id, pos); + } + + /** + * Clear the current selection + */ + clearSelection() { + this.#setActive(null, null); + } + + /** + * Get the currently active item ID + * @returns {string | null} + */ + getActiveId() { + return this.#activeId; + } +} + diff --git a/website/scripts/MarkdownRenderer.js b/website/scripts/MarkdownRenderer.js new file mode 100644 index 0000000..9426d78 --- /dev/null +++ b/website/scripts/MarkdownRenderer.js @@ -0,0 +1,175 @@ +/** + * Lightweight markdown-to-HTML renderer. + * Supports: headings (h1–h3), bold, italic, inline code, code blocks, + * blockquotes, unordered lists, ordered lists, horizontal rules, links, paragraphs. + * No external dependencies. + */ + +/** @type {Array<{ pattern: RegExp, svg: string }>} */ +const LINK_ICONS = [ + { + pattern: /youtube\.com|youtu\.be/, + svg: ``, + }, + { + pattern: /github\.com/, + svg: ``, + }, + { + pattern: /bsky\.app/, + svg: ``, + }, + { + pattern: /matrix\.to|matrix\.org/, + svg: ``, + }, +]; +export class MarkdownRenderer { + /** + * Converts a markdown string to sanitized HTML. + * + * @param {string} markdown - Raw markdown text. + * @returns {string} HTML string safe for innerHTML insertion. + */ + static render(markdown) { + let html = markdown + // Normalize line endings + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + + // Split into blocks separated by blank lines + const blocks = html.split(/\n{2,}/); + const parts = blocks.map(block => MarkdownRenderer.#renderBlock(block.trim())); + return parts.filter(Boolean).join("\n"); + } + + // ─── Block-level ────────────────────────────────────────────────────────── + + /** + * @param {string} block + * @returns {string} + */ + static #renderBlock(block) { + if (!block) return ""; + + // Fenced code block + const fenceMatch = block.match(/^```(\w*)\n([\s\S]*?)```$/); + if (fenceMatch) { + const lang = MarkdownRenderer.#escape(fenceMatch[1] || ""); + const code = MarkdownRenderer.#escape(fenceMatch[2]); + return `
${code}
`; + } + + // Horizontal rule + if (/^[-*_]{3,}$/.test(block)) { + return `
`; + } + + // Headings + const headingMatch = block.match(/^(#{1,3})\s+(.+)$/m); + if (headingMatch && block.split("\n").length === 1) { + const level = headingMatch[1].length; + return `${MarkdownRenderer.#renderInline(headingMatch[2])}`; + } + + // Blockquote + if (/^> /.test(block)) { + const inner = block.replace(/^> ?/gm, ""); + return `
${MarkdownRenderer.#renderInline(inner)}
`; + } + + // Unordered list + if (/^[-*+] /.test(block)) { + const items = block.split("\n").filter(Boolean).map(line => { + const content = line.replace(/^[-*+]\s+/, ""); + return `
  • ${MarkdownRenderer.#renderInline(content)}
  • `; + }); + return `
      ${items.join("")}
    `; + } + + // Ordered list + if (/^\d+\. /.test(block)) { + const items = block.split("\n").filter(Boolean).map(line => { + const content = line.replace(/^\d+\.\s+/, ""); + return `
  • ${MarkdownRenderer.#renderInline(content)}
  • `; + }); + return `
      ${items.join("")}
    `; + } + + // Paragraph (handle single-line line breaks within block) + const lines = block.split("\n").map(l => MarkdownRenderer.#renderInline(l)); + return `

    ${lines.join("
    ")}

    `; + } + + // ─── Inline-level ───────────────────────────────────────────────────────── + + /** + * @param {string} text + * @returns {string} + */ + static #renderInline(text) { + // Escape HTML first, then re-apply formatting + let out = MarkdownRenderer.#escape(text); + + // Inline code (must come before bold/italic to avoid breaking backtick content) + out = out.replace(/`([^`]+)`/g, (_, code) => + `${code}` + ); + + // Bold + italic + out = out.replace(/\*\*\*(.+?)\*\*\*/g, "$1"); + + // Bold + out = out.replace(/\*\*(.+?)\*\*/g, "$1"); + + // Italic + out = out.replace(/\*(.+?)\*/g, "$1"); + + // Links [text](url) + out = out.replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + (_, linkText, url) => { + const safeUrl = MarkdownRenderer.#sanitizeUrl(url); + if (!safeUrl) return linkText; + const iconEntry = LINK_ICONS.find(e => e.pattern.test(safeUrl)); + const prefix = iconEntry ? iconEntry.svg : ""; + const pipeIdx = linkText.indexOf("|"); + const inner = pipeIdx !== -1 + ? `${linkText.slice(0, pipeIdx)}${linkText.slice(pipeIdx + 1)}` + : linkText; + return `${prefix}${inner}`; + } + ); + + return out; + } + + // ─── Utilities ──────────────────────────────────────────────────────────── + + /** + * Escapes HTML special characters. + * + * @param {string} str + * @returns {string} + */ + static #escape(str) { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + /** + * Returns a URL only if it uses a safe scheme; otherwise null. + * + * @param {string} url + * @returns {string | null} + */ + static #sanitizeUrl(url) { + const trimmed = url.trim(); + if (/^(https?:\/\/|mailto:|\/|#|\.)/.test(trimmed)) return trimmed; + return null; + } +} diff --git a/website/scripts/NodeRenderer.js b/website/scripts/NodeRenderer.js new file mode 100644 index 0000000..496673c --- /dev/null +++ b/website/scripts/NodeRenderer.js @@ -0,0 +1,715 @@ +import { getTypeColor } from "./getTypeColor.js"; +import { MarkdownRenderer } from "./MarkdownRenderer.js"; +import { NodeRegistry, NodeRenderContext } from "./nodes/index.js"; + +/** + * @typedef {import('./GraphNode.js').GraphNode} GraphNode + * @typedef {import('./GraphNode.js').PinDescriptor} PinDescriptor + * @typedef {import('./GraphConnection.js').GraphConnection} GraphConnection + * @typedef {import('./GraphComment.js').GraphComment} GraphComment + */ + +/** + * Renders static blueprint nodes into a DOM layer. + * Stripped-down port of WorkspaceNodeRenderer — no drag, no selection, no context menus. + */ +export class NodeRenderer { + /** @type {HTMLElement} */ + #nodeLayer; + + /** @type {HTMLTemplateElement} */ + #nodeTemplate; + + /** @type {HTMLTemplateElement} */ + #pinTemplate; + + /** @type {Map} */ + #nodes = new Map(); + + /** @type {Map} */ + #nodeElements = new Map(); + + /** @type {Map} */ + #connections = new Map(); + + /** @type {Map} */ + #comments = new Map(); + + /** @type {Map} */ + #commentElements = new Map(); + + /** @type {HTMLElement | null} */ + #commentLayer = null; + + /** + * Active interval handles keyed by timer node ID. + * + * @type {Map} + */ + #timerIntervals = new Map(); + + /** @type {((nodeId: string, pinId: string, direction: string) => void) | null} */ + #onNodeClick = null; + + /** @type {((nodeId: string, pinId?: string) => void) | null} */ + #onNodeExecute = null; + + /** @type {(() => void) | null} */ + #onConnectionRemoved = null; + + /** + * Promises for all async markdown loads, used by contentReady(). + * + * @type {Promise[]} + */ + #markdownPromises = []; + + /** + * Active chat node instances keyed by node ID. + * + * @type {Map} + */ + #chatInstances = new Map(); + + /** @type {NodeRenderContext | null} */ + #renderCtx = null; + + /** + * @param {HTMLElement} nodeLayer + * @param {HTMLTemplateElement} nodeTemplate + * @param {HTMLTemplateElement} pinTemplate + * @param {((nodeId: string, pinId: string, direction: string) => void) | null} [onNodeClick] + * @param {((nodeId: string, pinId?: string) => void) | null} [onNodeExecute] + * @param {(() => void) | null} [onConnectionRemoved] + */ + constructor(nodeLayer, nodeTemplate, pinTemplate, onNodeClick = null, onNodeExecute = null, onConnectionRemoved = null) { + this.#nodeLayer = nodeLayer; + this.#nodeTemplate = nodeTemplate; + this.#pinTemplate = pinTemplate; + this.#onNodeClick = onNodeClick; + this.#onNodeExecute = onNodeExecute; + this.#onConnectionRemoved = onConnectionRemoved; + this.#renderCtx = new NodeRenderContext({ + loadMarkdown: (src, target) => this.#loadMarkdown(src, target), + addMarkdownPromise: (p) => this.#markdownPromises.push(p), + onNodeExecute: (nodeId, pinId) => this.#onNodeExecute?.(nodeId, pinId), + registerChatInstance: (nodeId, instance) => this.#chatInstances.set(nodeId, instance), + startTimer: (nodeId, toggle) => this.#startTimer(nodeId, toggle), + stopTimer: (nodeId) => this.#stopTimer(nodeId), + svgPlay: () => NodeRenderer.#svgPlay(), + svgStop: () => NodeRenderer.#svgStop(), + resolveInputValue: (nodeId, pinId) => this.#resolveInputValue(nodeId, pinId), + }); + } + + // ─── Public ─────────────────────────────────────────────────────────────── + + /** + * Adds a node to the graph and renders it into the DOM. + * + * @param {GraphNode} node + */ + addNode(node) { + this.#nodes.set(node.id, node); + this.#renderNode(node); + } + + /** + * Adds a comment box to the graph and renders it into the DOM. + * + * @param {GraphComment} comment + */ + addComment(comment) { + this.#comments.set(comment.id, comment); + this.#renderComment(comment); + } + + /** + * Removes all connections touching a specific pin and refreshes pin state. + * Calls onConnectionRemoved if any were removed. + * + * @param {string} nodeId + * @param {string} pinId + * @param {('input'|'output')} direction + */ + disconnectPin(nodeId, pinId, direction) { + let removed = false; + for (const [id, conn] of this.#connections) { + const matches = direction === "output" + ? conn.from.nodeId === nodeId && conn.from.pinId === pinId + : conn.to.nodeId === nodeId && conn.to.pinId === pinId; + if (matches) { + const otherNodeId = direction === "output" ? conn.to.nodeId : conn.from.nodeId; + this.#connections.delete(id); + this.#refreshPinStates(nodeId); + this.#refreshPinStates(otherNodeId); + removed = true; + } + } + if (removed) this.#onConnectionRemoved?.(); + } + + /** + * Adds a connection with kind-aware deduplication: + * - Exec pins: one-out (only one connection may leave a given exec output), many-in allowed. + * - Data pins: many-out allowed, one-in (only one connection may enter a given data input). + * + * @param {GraphConnection} connection + */ + addConnection(connection) { + /** @type {Set} */ + const staleNodes = new Set(); + + for (const [id, existing] of this.#connections) { + const isDuplicate = connection.kind === "exec" + ? existing.from.nodeId === connection.from.nodeId && existing.from.pinId === connection.from.pinId + : existing.to.nodeId === connection.to.nodeId && existing.to.pinId === connection.to.pinId; + + if (isDuplicate) { + staleNodes.add(existing.from.nodeId); + staleNodes.add(existing.to.nodeId); + this.#connections.delete(id); + break; + } + } + + this.#connections.set(connection.id, connection); + + staleNodes.add(connection.from.nodeId); + staleNodes.add(connection.to.nodeId); + for (const nodeId of staleNodes) this.#refreshPinStates(nodeId); + } + + /** + * Returns all current connections. + * + * @returns {GraphConnection[]} + */ + getConnections() { + return [...this.#connections.values()]; + } + + /** + * Returns all current nodes. + * + * @returns {GraphNode[]} + */ + getNodes() { + return [...this.#nodes.values()]; + } + + /** + * Returns the world-space bounding rect of a node using its position and DOM size. + * + * @param {string} nodeId + * @returns {{ x: number, y: number, width: number, height: number } | null} + */ + /** + * Returns a promise that resolves once all markdown content has finished loading. + * Uses allSettled so a single failed fetch does not block others. + * + * @returns {Promise} + */ + contentReady() { + return Promise.allSettled(this.#markdownPromises).then(() => {}); + } + + getNodeWorldRect(nodeId) { + const node = this.#nodes.get(nodeId); + const el = this.#nodeElements.get(nodeId); + if (!node || !el) return null; + return { + x: node.position.x, + y: node.position.y, + width: el.offsetWidth || 220, + height: el.offsetHeight || 80, + }; + } + + /** + * Returns the first node connected to the given node (via any connection). + * + * @param {string} nodeId + * @returns {string | null} The other node's ID, or null. + */ + getFirstConnectedNodeId(nodeId) { + for (const conn of this.#connections.values()) { + if (conn.from.nodeId === nodeId) return conn.to.nodeId; + if (conn.to.nodeId === nodeId) return conn.from.nodeId; + } + return null; + } + + /** + * Returns the node ID connected to a specific pin. + * + * @param {string} nodeId + * @param {string} pinId + * @param {('input'|'output')} direction + * @returns {string | null} + */ + getConnectedNodeId(nodeId, pinId, direction) { + for (const conn of this.#connections.values()) { + if (direction === "output" && conn.from.nodeId === nodeId && conn.from.pinId === pinId) return conn.to.nodeId; + if (direction === "input" && conn.to.nodeId === nodeId && conn.to.pinId === pinId) return conn.from.nodeId; + } + return null; + } + + /** + * Returns the DOM element for a node. + * + * @param {string} nodeId + * @returns {HTMLElement | undefined} + */ + getNodeElement(nodeId) { + return this.#nodeElements.get(nodeId); + } + + /** + * Returns the pin handle element for use in connection geometry calculations. + * + * @param {string} nodeId + * @param {string} pinId + * @param {('input'|'output')} direction + * @returns {HTMLElement | null} + */ + getPinHandle(nodeId, pinId, direction) { + const article = this.#nodeElements.get(nodeId); + if (!article) return null; + const container = direction === "input" + ? article.querySelector(".node-inputs") + : article.querySelector(".node-outputs"); + if (!container) return null; + const pin = container.querySelector(`[data-pin-id="${pinId}"]`); + return pin ? pin.querySelector(".pin-handle") : null; + } + + /** + * Updates a node's world-space position and its DOM transform, with optional rotation for sway. + * Rotation formula matches WorkspaceGeometry.positionToTransform in Picograph. + * + * @param {string} nodeId + * @param {number} x + * @param {number} y + * @param {number} [rotation] - Z-rotation in degrees; also produces a perspective Y-axis lean. + */ + setNodeTransform(nodeId, x, y, rotation = 0) { + const node = this.#nodes.get(nodeId); + const el = this.#nodeElements.get(nodeId); + if (!node || !el) return; + node.position.x = x; + node.position.y = y; + el.style.transform = NodeRenderer.#buildTransform(x, y, rotation); + } + + /** + * Updates a node's world-space position (no rotation). + * + * @param {string} nodeId + * @param {number} x + * @param {number} y + */ + setNodePosition(nodeId, x, y) { + this.setNodeTransform(nodeId, x, y, 0); + } + + /** + * Builds a CSS transform string from a world position and optional rotation. + * + * @param {number} x + * @param {number} y + * @param {number} rotation + * @returns {string} + */ + static #buildTransform(x, y, rotation) { + if (!rotation) return `translate3d(${x}px, ${y}px, 0)`; + return `translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg)`; + } + + /** + * Returns all rendered node elements keyed by node ID. + * + * @returns {Map} + */ + getNodeElements() { + return this.#nodeElements; + } + + // ─── Rendering ──────────────────────────────────────────────────────────── + + /** @param {GraphNode} node */ + #renderNode(node) { + const fragment = /** @type {DocumentFragment} */ (this.#nodeTemplate.content.cloneNode(true)); + const article = /** @type {HTMLElement} */ (fragment.querySelector(".blueprint-node")); + const title = /** @type {HTMLElement} */ (article.querySelector(".node-title")); + const inputs = /** @type {HTMLElement} */ (article.querySelector(".node-inputs")); + const outputs = /** @type {HTMLElement} */ (article.querySelector(".node-outputs")); + + article.dataset.nodeId = node.id; + article.dataset.nodeType = node.type; + title.textContent = node.title; + article.style.transform = `translate3d(${node.position.x}px, ${node.position.y}px, 0)`; + if (node.width != null) article.style.width = `${node.width}px`; + + inputs.innerHTML = ""; + outputs.innerHTML = ""; + + node.inputs.forEach(pin => { + const el = this.#createPinElement(node.id, pin, "input"); + inputs.appendChild(el); + }); + + node.outputs.forEach(pin => { + const el = this.#createPinElement(node.id, pin, "output"); + outputs.appendChild(el); + }); + + // Dispatch type-specific rendering to the registered node handler + const handler = NodeRegistry.BlueprintPure_Get(node.type); + if (handler) { + handler.BlueprintNativeEvent_OnRender(article, node, this.#renderCtx); + } + + this.#nodeLayer.appendChild(article); + this.#nodeElements.set(node.id, article); + } + + /** + * Fetches a markdown file and renders it into the target element. + * + * @param {string} src - Relative URL to the .md file. + * @param {HTMLElement} target - Container to populate. + * @returns {Promise} + */ + async #loadMarkdown(src, target) { + try { + const response = await fetch(src); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const text = await response.text(); + target.innerHTML = MarkdownRenderer.render(text); + } catch (err) { + target.innerHTML = `

    Failed to load content.

    `; + console.error(`[NodeRenderer] Could not load markdown "${src}":`, err); + } + } + + /** + * Renders a comment box into the DOM. + * + * @param {GraphComment} comment + */ + #renderComment(comment) { + // Create comment layer if it doesn't exist + if (!this.#commentLayer) { + this.#commentLayer = document.createElement("div"); + this.#commentLayer.className = "comment-layer"; + this.#nodeLayer.insertBefore(this.#commentLayer, this.#nodeLayer.firstChild); + } + + const box = document.createElement("div"); + box.className = "blueprint-comment"; + box.dataset.commentId = comment.id; + box.style.transform = `translate3d(${comment.position.x}px, ${comment.position.y}px, 0)`; + box.style.width = `${comment.size.width}px`; + box.style.height = `${comment.size.height}px`; + + // Create the colored background layer + const bg = document.createElement("div"); + bg.className = "comment-background"; + bg.style.backgroundColor = comment.color; + bg.style.opacity = comment.opacity.toString(); + box.appendChild(bg); + + const titleBar = document.createElement("div"); + titleBar.className = "comment-title"; + titleBar.textContent = comment.title; + box.appendChild(titleBar); + + this.#commentLayer.appendChild(box); + this.#commentElements.set(comment.id, box); + } + + // ─── Timer ──────────────────────────────────────────────────────────────── + + /** + * Starts the interval loop for a timer node, updating toggle button state. + * + * @param {string} nodeId + * @param {HTMLButtonElement} toggle + */ + #startTimer(nodeId, toggle) { + this.#stopTimer(nodeId); + + const node = this.#nodes.get(nodeId); + const intervalPin = node?.inputs.find(p => p.id === "interval"); + const seconds = Math.max(0.1, Math.min(10, parseFloat(intervalPin?.defaultValue ?? "1") || 1)); + + toggle.dataset.running = "true"; + toggle.innerHTML = NodeRenderer.#svgStop(); + + const handle = window.setInterval(() => { + this.#onNodeExecute?.(nodeId); + }, seconds * 1000); + + this.#timerIntervals.set(nodeId, handle); + } + + /** + * Stops a running timer and resets the toggle button state. + * + * @param {string} nodeId + */ + #stopTimer(nodeId) { + const handle = this.#timerIntervals.get(nodeId); + if (handle != null) { + window.clearInterval(handle); + this.#timerIntervals.delete(nodeId); + } + + const el = this.#nodeElements.get(nodeId); + const toggle = /** @type {HTMLButtonElement | null} */ (el?.querySelector(".node-timer-btn")); + if (toggle) { + toggle.dataset.running = "false"; + toggle.innerHTML = NodeRenderer.#svgPlay(); + toggle.title = "Start timer"; + } + } + + /** @returns {string} */ + static #svgPlay() { + return ``; + } + + /** @returns {string} */ + static #svgStop() { + return ``; + } + + /** + * Resolves input value by following connections (for render-time use). + * + * @param {string} nodeId + * @param {string} pinId + * @returns {any} + */ + #resolveInputValue(nodeId, pinId) { + try { + console.log('[NodeRenderer#resolveInputValue] Looking for', nodeId, pinId); + console.log('[NodeRenderer#resolveInputValue] Available connections:', this.#connections); + console.log('[NodeRenderer#resolveInputValue] Available nodes:', Array.from(this.#nodes.keys())); + + const conn = Array.from(this.#connections.values()).find( + c => c.to.nodeId === nodeId && c.to.pinId === pinId + ); + + console.log('[NodeRenderer#resolveInputValue] Found connection:', conn); + + if (conn) { + const sourceNode = this.#nodes.get(conn.from.nodeId); + + console.log('[NodeRenderer] Resolving', nodeId, pinId, 'from', conn.from.nodeId, 'sourceNode:', sourceNode); + + // Handle random_name node + if (sourceNode?.type === "random_name" && conn.from.pinId === "name") { + console.log('[NodeRenderer] Random name value:', sourceNode._generatedName); + return sourceNode._generatedName || ""; + } + + // Fallback to source pin defaultValue + const sourcePin = sourceNode?.getPin?.(conn.from.pinId); + if (sourcePin?.defaultValue != null) { + return sourcePin.defaultValue; + } + } + + // No connection, use local pin defaultValue + const node = this.#nodes.get(nodeId); + const pin = node?.getPin?.(pinId); + return pin?.defaultValue ?? ""; + } catch (err) { + console.error(`Failed to resolve input ${nodeId}.${pinId}:`, err); + return ""; + } + } + + /** + * @param {string} nodeId + * @param {PinDescriptor} pin + * @param {('input'|'output')} direction + * @returns {HTMLElement} + */ + #createPinElement(nodeId, pin, direction) { + const fragment = /** @type {DocumentFragment} */ (this.#pinTemplate.content.cloneNode(true)); + const container = /** @type {HTMLElement} */ (fragment.firstElementChild); + const label = /** @type {HTMLElement} */ (container.querySelector(".pin-label")); + const handle = /** @type {HTMLElement} */ (container.querySelector(".pin-handle")); + + container.dataset.pinId = pin.id; + container.dataset.type = pin.kind; + container.dataset.direction = direction; + container.style.setProperty("--pin-kind-color", getTypeColor(pin.kind)); + + // Exec and string pins are clickable: focus the connected node + if (this.#onNodeClick && (pin.kind === "exec" || pin.kind === "string")) { + container.classList.add("is-clickable"); + + /** @param {MouseEvent} e */ + const handleClick = (e) => { + e.stopPropagation(); + if (e.altKey) return; + if (container.classList.contains("is-disconnected")) return; + this.#onNodeClick(nodeId, pin.id, direction); + }; + + handle.addEventListener("click", handleClick); + label.addEventListener("click", handleClick); + } + + // Alt+click on any pin handle disconnects it + handle.addEventListener("click", (e) => { + if (!e.altKey) return; + e.stopPropagation(); + this.disconnectPin(nodeId, pin.id, direction); + }); + + const isStandardExec = pin.kind === "exec" && (pin.id === "exec_in" || pin.id === "exec_out"); + if (isStandardExec && !pin.name) { + label.textContent = ""; + label.classList.add("is-hidden"); + } else { + label.textContent = pin.name; + } + + // String pins: render an editable inline value chip + // For outputs: only on pure/string nodes + // For inputs: always show if there's a defaultValue + const node = this.#nodes.get(nodeId); + const isPureOrStringNode = node && (node.type === "pure" || node.type === "string"); + const shouldShowStringValue = pin.kind === "string" && pin.defaultValue != null && + ((direction === "output" && isPureOrStringNode) || direction === "input"); + + if (shouldShowStringValue) { + const valueChip = document.createElement("span"); + valueChip.className = "pin-value"; + valueChip.textContent = pin.defaultValue; + valueChip.contentEditable = "true"; + valueChip.spellcheck = false; + + // Prevent workspace pan/drag from stealing pointer events while editing + valueChip.addEventListener("pointerdown", (e) => e.stopPropagation()); + + // Prevent pin click navigation from firing when clicking into the chip + valueChip.addEventListener("click", (e) => e.stopPropagation()); + + // Keep caret inside on Enter instead of inserting a
    + valueChip.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + valueChip.blur(); + } + }); + + // Sync back to the live pin descriptor so GraphExecutor reads the new value + valueChip.addEventListener("input", () => { + pin.defaultValue = valueChip.textContent ?? ""; + }); + + // Clean up any accidental HTML on blur + valueChip.addEventListener("blur", () => { + const text = valueChip.textContent ?? ""; + pin.defaultValue = text; + valueChip.textContent = text; + }); + + container.appendChild(valueChip); + } + + // Number pins with defaultValue: render a clamped numeric input on inputs only + if (pin.kind === "number" && pin.defaultValue != null && direction === "input") { + const numInput = document.createElement("input"); + numInput.type = "number"; + numInput.className = "pin-value pin-value--number"; + numInput.value = pin.defaultValue; + if (pin.min != null) numInput.min = String(pin.min); + if (pin.max != null) numInput.max = String(pin.max); + numInput.step = "0.1"; + + numInput.addEventListener("pointerdown", (e) => e.stopPropagation()); + numInput.addEventListener("click", (e) => e.stopPropagation()); + + const clampAndSync = () => { + let v = parseFloat(numInput.value); + if (!Number.isFinite(v)) v = parseFloat(pin.defaultValue ?? "1"); + if (pin.min != null) v = Math.max(pin.min, v); + if (pin.max != null) v = Math.min(pin.max, v); + const str = String(Math.round(v * 100) / 100); + numInput.value = str; + pin.defaultValue = str; + }; + + numInput.addEventListener("change", clampAndSync); + numInput.addEventListener("blur", clampAndSync); + + container.appendChild(numInput); + } + + // Start disconnected — addConnection() will update these + container.classList.add("is-disconnected"); + + return container; + } + + /** + * Re-evaluates connected/disconnected classes for all pins on a node. + * + * @param {string} nodeId + */ + #refreshPinStates(nodeId) { + const node = this.#nodes.get(nodeId); + if (!node) return; + [...node.inputs, ...node.outputs].forEach(pin => { + this.#applyPinState(nodeId, pin.id, pin.direction); + }); + } + + /** + * @param {string} nodeId + * @param {string} pinId + * @param {('input'|'output')} direction + */ + #applyPinState(nodeId, pinId, direction) { + const handle = this.getPinHandle(nodeId, pinId, direction); + const pin = this.#findPinContainer(nodeId, pinId, direction); + if (!pin) return; + + const connected = [...this.#connections.values()].some(c => + (c.from.nodeId === nodeId && c.from.pinId === pinId) || + (c.to.nodeId === nodeId && c.to.pinId === pinId) + ); + + pin.classList.toggle("is-connected", connected); + pin.classList.toggle("is-disconnected", !connected); + + // Hide inline value chip on input pins when connected + if (direction === "input") { + const chip = pin.querySelector(".pin-value"); + if (chip) chip.style.display = connected ? "none" : ""; + } + } + + /** + * @param {string} nodeId + * @param {string} pinId + * @param {('input'|'output')} direction + * @returns {HTMLElement | null} + */ + #findPinContainer(nodeId, pinId, direction) { + const article = this.#nodeElements.get(nodeId); + if (!article) return null; + const section = direction === "input" + ? article.querySelector(".node-inputs") + : article.querySelector(".node-outputs"); + return section?.querySelector(`[data-pin-id="${pinId}"]`) ?? null; + } +} diff --git a/website/scripts/PinConnectionManager.js b/website/scripts/PinConnectionManager.js new file mode 100644 index 0000000..bc8b378 --- /dev/null +++ b/website/scripts/PinConnectionManager.js @@ -0,0 +1,516 @@ +import { getTypeColor } from "./getTypeColor.js"; +import { GraphConnection } from "./GraphConnection.js"; + +/** + * @typedef {import('./NodeRenderer.js').NodeRenderer} NodeRenderer + * @typedef {import('./WorkspaceNavigator.js').WorkspaceNavigator} WorkspaceNavigator + * @typedef {import('./ConnectionRenderer.js').ConnectionRenderer} ConnectionRenderer + */ + +/** + * @typedef {{ nodeId: string, pinId: string }} PinRef + */ + +/** + * @typedef {{ + * direction: 'input' | 'output', + * source: PinRef | undefined, + * target: PinRef | undefined, + * path: SVGPathElement, + * circle: SVGCircleElement, + * anchorX: number, + * anchorY: number, + * lastPointerX: number, + * lastPointerY: number, + * }} PendingConnection + */ + +/** + * Handles pin-to-pin connection dragging in world-space. + * + * Mirrors the gesture flow from Picograph's BlueprintWorkspace: + * pointerdown on handle → temp SVG path follows cursor → pointerup on + * compatible pin → commit connection. + * + * Only active when {@link PinConnectionManager.enabled} is true. + */ +export class PinConnectionManager { + /** @type {HTMLElement} */ + #canvas; + + /** @type {SVGElement} */ + #svg; + + /** @type {HTMLElement} */ + #nodeLayer; + + /** @type {NodeRenderer} */ + #nodeRenderer; + + /** @type {WorkspaceNavigator} */ + #nav; + + /** @type {ConnectionRenderer} */ + #connRenderer; + + /** @type {PendingConnection | null} */ + #pending = null; + + /** @type {boolean} */ + #enabled = false; + + /** + * @param {HTMLElement} canvas - Workspace canvas element. + * @param {SVGElement} svg - Connection layer SVG element. + * @param {HTMLElement} nodeLayer - Node layer element. + * @param {NodeRenderer} nodeRenderer + * @param {WorkspaceNavigator} nav + * @param {ConnectionRenderer} connRenderer + */ + constructor(canvas, svg, nodeLayer, nodeRenderer, nav, connRenderer) { + this.#canvas = canvas; + this.#svg = svg; + this.#nodeLayer = nodeLayer; + this.#nodeRenderer = nodeRenderer; + this.#nav = nav; + this.#connRenderer = connRenderer; + + this.#nodeLayer.addEventListener("pointerdown", (e) => { + if (!this.#enabled) return; + this.#handleNodeLayerPointerDown(e); + }); + + this.#nodeLayer.addEventListener("pointerenter", (e) => this.#handlePinHover(e, true), true); + this.#nodeLayer.addEventListener("pointerleave", (e) => this.#handlePinHover(e, false), true); + } + + // ─── Public ─────────────────────────────────────────────────────────────── + + /** @param {boolean} v */ + set enabled(v) { this.#enabled = v; } + + /** @returns {boolean} */ + get enabled() { return this.#enabled; } + + /** @returns {PendingConnection | null} */ + get pending() { return this.#pending; } + + // ─── Private — event detection ───────────────────────────────────────────── + + /** + * Highlights or clears connection wires when the pointer enters or leaves a pin handle. + * + * @param {PointerEvent} e + * @param {boolean} entering + */ + #handlePinHover(e, entering) { + const target = /** @type {HTMLElement} */ (e.target); + const handle = target.closest(".pin-handle"); + if (!handle) return; + + const pinContainer = handle.closest("[data-pin-id]"); + const nodeArticle = handle.closest("[data-node-id]"); + if (!pinContainer || !nodeArticle) return; + + const nodeId = /** @type {HTMLElement} */ (nodeArticle).dataset.nodeId; + const pinId = /** @type {HTMLElement} */ (pinContainer).dataset.pinId; + if (!nodeId || !pinId) return; + + if (entering) { + this.#connRenderer.highlightPin(nodeId, pinId); + } else { + this.#connRenderer.clearHighlight(); + } + } + + /** + * @param {PointerEvent} e + */ + #handleNodeLayerPointerDown(e) { + if (e.button !== 0) return; + + const target = /** @type {HTMLElement} */ (e.target); + const handle = target.closest(".pin-handle"); + if (!handle) return; + + const pinContainer = handle.closest("[data-pin-id]"); + const nodeArticle = handle.closest("[data-node-id]"); + if (!pinContainer || !nodeArticle) return; + + const nodeId = /** @type {HTMLElement} */ (nodeArticle).dataset.nodeId; + const pinId = /** @type {HTMLElement} */ (pinContainer).dataset.pinId; + const direction = /** @type {'input'|'output'} */ ( + /** @type {HTMLElement} */ (pinContainer).dataset.direction + ); + + if (!nodeId || !pinId || !direction) return; + + // Retrieve the pin kind so we can colour the temp wire + const node = this.#nodeRenderer.getNodes().find(n => n.id === nodeId); + if (!node) return; + const pin = node.getPin(pinId); + if (!pin) return; + + this.#startPendingConnectionGesture(e, nodeId, pinId, direction, pin.kind); + } + + // ─── Private — gesture ──────────────────────────────────────────────────── + + /** + * Mirrors Picograph's `#startPendingConnectionGesture`. + * + * @param {PointerEvent} event + * @param {string} nodeId + * @param {string} pinId + * @param {'input'|'output'} direction + * @param {string} kind + */ + #startPendingConnectionGesture(event, nodeId, pinId, direction, kind) { + if (this.#pending) this.#cancelPendingConnection(); + + event.stopPropagation(); + event.preventDefault(); + + const path = this.#createConnectionPath(kind); + this.#svg.appendChild(path); + + // Add endpoint circle for visual cursor alignment + const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circle.setAttribute("r", "4"); + circle.setAttribute("fill", getTypeColor(kind)); + circle.setAttribute("opacity", "0.9"); + circle.dataset.pending = "true"; + this.#svg.appendChild(circle); + + // Compute initial anchor position in screen-space + const canvasRect = this.#canvas.getBoundingClientRect(); + const startHandle = this.#nodeRenderer.getPinHandle(nodeId, pinId, direction); + const startRect = startHandle ? startHandle.getBoundingClientRect() : null; + const initAnchorX = startRect ? startRect.left - canvasRect.left + startRect.width / 2 : 0; + const initAnchorY = startRect ? startRect.top - canvasRect.top + startRect.height / 2 : 0; + + this.#pending = { + direction, + source: direction === "output" ? { nodeId, pinId } : undefined, + target: direction === "input" ? { nodeId, pinId } : undefined, + path, + circle, + anchorX: initAnchorX, + anchorY: initAnchorY, + lastPointerX: initAnchorX, + lastPointerY: initAnchorY, + }; + + const handlePointerMove = (/** @type {PointerEvent} */ moveE) => { + if (moveE.pointerId !== event.pointerId) return; + this.#updatePendingConnectionPath(moveE.clientX, moveE.clientY, nodeId, pinId, direction); + }; + + const handlePointerUp = (/** @type {PointerEvent} */ upE) => { + if (upE.pointerId !== event.pointerId) return; + + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + + this.#tryFinalizeAtPoint(upE.clientX, upE.clientY); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + } + + /** + * Mirrors Picograph's `#updatePendingConnectionPath`. + * + * @param {number} clientX + * @param {number} clientY + * @param {string} nodeId + * @param {string} pinId + * @param {'input'|'output'} direction + */ + #updatePendingConnectionPath(clientX, clientY, nodeId, pinId, direction) { + if (!this.#pending) return; + + const startHandle = this.#nodeRenderer.getPinHandle(nodeId, pinId, direction); + if (!startHandle) return; + + const canvasRect = this.#canvas.getBoundingClientRect(); + const startRect = startHandle.getBoundingClientRect(); + + // Screen-space coordinates + const anchor = { + x: startRect.left - canvasRect.left + startRect.width / 2, + y: startRect.top - canvasRect.top + startRect.height / 2, + }; + const pointer = { + x: clientX - canvasRect.left, + y: clientY - canvasRect.top, + }; + + // Keep anchor and pointer up-to-date for springback animation + this.#pending.anchorX = anchor.x; + this.#pending.anchorY = anchor.y; + this.#pending.lastPointerX = pointer.x; + this.#pending.lastPointerY = pointer.y; + + const start = direction === "output" ? anchor : pointer; + const end = direction === "output" ? pointer : anchor; + + const cpOffset = Math.max(60, Math.abs(end.x - start.x) * 0.5); + + // Lerp pointer-side control point based on drag direction + // When pointer.x >= anchor.x: factor = 0 (forward, offset = -cpOffset) + // When pointer.x < anchor.x: factor increases (backward, offset lerps to +cpOffset) + const dragDelta = pointer.x - anchor.x; + const lerpFactor = Math.max(0, Math.min(1, -dragDelta / (cpOffset * 2))); + const pointerCpOffset = -cpOffset + (lerpFactor * cpOffset * 2); // lerp from -cpOffset to +cpOffset + + // Make the pointer-side control point curve toward the pin + let cp1x, cp1y, cp2x, cp2y; + if (direction === "output") { + // start = anchor (pin), end = pointer + // Anchor side always extends right, pointer side lerps + cp1x = start.x + cpOffset; + cp1y = start.y; + cp2x = end.x + pointerCpOffset; + cp2y = end.y + (anchor.y - pointer.y) * 0.5; // curve toward anchor + } else { + // start = pointer, end = anchor (pin) + // Pointer side lerps, anchor side always extends left + cp1x = start.x + pointerCpOffset; + cp1y = start.y + (anchor.y - pointer.y) * 0.5; // curve toward anchor + cp2x = end.x - cpOffset; + cp2y = end.y; + } + + const d = `M ${start.x} ${start.y} C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${end.x} ${end.y}`; + + this.#pending.path.dataset.active = "true"; + this.#pending.path.setAttribute("d", d); + + // Update circle position to pointer location + this.#pending.circle.setAttribute("cx", pointer.x.toString()); + this.#pending.circle.setAttribute("cy", pointer.y.toString()); + } + + /** + * Checks the elements under the pointer on release for a compatible pin. + * Mirrors the drop detection pattern from Picograph's WorkspaceNodeRenderer's + * `pointerup` handler on pin containers. + * + * @param {number} clientX + * @param {number} clientY + */ + #tryFinalizeAtPoint(clientX, clientY) { + if (!this.#pending) return; + + const elements = document.elementsFromPoint(clientX, clientY); + for (const el of elements) { + if (!(el instanceof HTMLElement)) continue; + + const pinContainer = el.closest("[data-pin-id]"); + const nodeArticle = el.closest("[data-node-id]"); + if (!pinContainer || !nodeArticle) continue; + + const targetNodeId = /** @type {HTMLElement} */ (nodeArticle).dataset.nodeId; + const targetPinId = /** @type {HTMLElement} */ (pinContainer).dataset.pinId; + const targetDirection = /** @type {'input'|'output'} */ ( + /** @type {HTMLElement} */ (pinContainer).dataset.direction + ); + + if (!targetNodeId || !targetPinId || !targetDirection) continue; + + this.#finalizeConnection(targetNodeId, targetPinId, targetDirection); + return; + } + + this.#cancelPendingConnection(); + } + + /** + * Mirrors Picograph's `#finalizeConnection`. + * + * @param {string} nodeId + * @param {string} pinId + * @param {'input'|'output'} direction + */ + #finalizeConnection(nodeId, pinId, direction) { + if (!this.#pending) return; + + const { source, target } = this.#pending; + + /** @type {PinRef | null} */ + let fromRef = null; + /** @type {PinRef | null} */ + let toRef = null; + + if (direction === "input" && source) { + fromRef = source; + toRef = { nodeId, pinId }; + } else if (direction === "output" && target) { + fromRef = { nodeId, pinId }; + toRef = target; + } + + if (fromRef && toRef && fromRef.nodeId !== toRef.nodeId) { + const fromNode = this.#nodeRenderer.getNodes().find(n => n.id === fromRef.nodeId); + const toNode = this.#nodeRenderer.getNodes().find(n => n.id === toRef.nodeId); + const fromPin = fromNode?.getPin(fromRef.pinId); + const toPin = toNode?.getPin(toRef.pinId); + + if (fromPin && toPin && this.#kindsCompatible(fromPin.kind, toPin.kind)) { + const conn = new GraphConnection(fromRef, toRef, fromPin.kind); + this.#nodeRenderer.addConnection(conn); + this.#pending.path.remove(); + this.#pending.circle.remove(); + this.#pending = null; + this.#connRenderer.render(); + return; + } + } + + this.#cancelPendingConnection(); + } + + /** + * Mirrors Picograph's `#cancelPendingConnection`. + * Launches a springback animation before removing the wire. + */ + #cancelPendingConnection() { + if (!this.#pending) return; + + const { path, circle, direction, anchorX, anchorY, lastPointerX, lastPointerY } = this.#pending; + this.#pending = null; + + // Remove circle immediately (no springback needed) + circle.remove(); + + this.#animateWireSpringback(path, direction, anchorX, anchorY, lastPointerX, lastPointerY); + } + + /** + * Retracts the wire with a whip character: the free end snaps home quickly + * while the bezier arch sags and decays at a slower rate, giving the visual + * impression of a real cable being released. + * + * @param {SVGPathElement} path + * @param {'input'|'output'} direction + * @param {number} anchorX - Anchor screen-space X. + * @param {number} anchorY - Anchor screen-space Y. + * @param {number} fromX - Free-end X at release. + * @param {number} fromY - Free-end Y at release. + */ + #animateWireSpringback(path, direction, anchorX, anchorY, fromX, fromY) { + // Endpoint snaps home fast; arch shape collapses slower — two different rates + // produce the non-linear whip character. + const endpointDecay = 0.022; // px/ms half-life ~31ms — fast snap + const archDecay = 0.007; // half-life ~99ms — slow arch collapse + const wobbleDecay = 0.008; // wobble amplitude decay (slower than endpoint, faster than arch) + + let px = fromX; + let py = fromY; + + // Initial sag magnitude: proportional to release distance, capped. + const releaseDist = Math.sqrt((fromX - anchorX) ** 2 + (fromY - anchorY) ** 2); + let sag = Math.min(releaseDist * 0.1, 30); // Reduced from 0.4/120 + let wobbleAmplitude = Math.min(releaseDist * 0.4, 80); // Increased from 0.3/60 + + let lastTime = performance.now(); + let phase = 0; + const wobbleFreq = 0.018; // radians per ms (~2.8 Hz) - increased frequency + + /** @param {number} now */ + const tick = (now) => { + const dt = Math.min(now - lastTime, 32); + lastTime = now; + + // Endpoint: fast exponential decay toward anchor + const ef = Math.exp(-endpointDecay * dt); + px = anchorX + (px - anchorX) * ef; + py = anchorY + (py - anchorY) * ef; + + // Arch sag: slower exponential decay + const af = Math.exp(-archDecay * dt); + sag *= af; + + // Wobble: amplitude decay + phase advance + const wf = Math.exp(-wobbleDecay * dt); + wobbleAmplitude *= wf; + phase += wobbleFreq * dt; + + const dx = anchorX - px; + const dy = anchorY - py; + const dist = Math.sqrt(dx * dx + dy * dy); + + const start = direction === "output" ? { x: anchorX, y: anchorY } : { x: px, y: py }; + const end = direction === "output" ? { x: px, y: py } : { x: anchorX, y: anchorY }; + + // Control points: standard horizontal tension + perpendicular sag offset + sine wave wobble + const mx = (start.x + end.x) / 2; + const my = (start.y + end.y) / 2; + const len = Math.max(dist, 1); + // Perpendicular unit vector (rotated 90°) + const perpX = -(end.y - start.y) / len; + const perpY = (end.x - start.x) / len; + + const tension = Math.max(20, dist * 0.45); + + // Apply sine wave wobble to anchor-side control point only + const wobble = Math.sin(phase) * wobbleAmplitude; + + // Anchor is at 'start' for output pins, 'end' for input pins + const cp1x = start.x + tension + perpX * (sag + (direction === "output" ? wobble : 0)); + const cp1y = start.y + perpY * (sag + (direction === "output" ? wobble : 0)); + const cp2x = end.x - tension + perpX * (sag + (direction === "input" ? wobble : 0)); + const cp2y = end.y + perpY * (sag + (direction === "input" ? wobble : 0)); + + path.setAttribute("d", + `M ${start.x} ${start.y} C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${end.x} ${end.y}` + ); + + if (dist < 1 && sag < 1 && wobbleAmplitude < 0.5) { + path.remove(); + } else { + requestAnimationFrame(tick); + } + }; + + requestAnimationFrame(tick); + } + + // ─── Private — helpers ──────────────────────────────────────────────────── + + /** + * Creates a styled SVG path element for the pending wire. + * Matches Picograph's `#createConnectionPath`. + * + * @param {string} kind + * @returns {SVGPathElement} + */ + #createConnectionPath(kind) { + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("fill", "none"); + path.setAttribute("stroke-width", "2.5"); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-dasharray", "6 4"); + path.style.stroke = getTypeColor(kind); + path.style.opacity = "0.8"; + path.dataset.kind = kind; + path.dataset.pending = "true"; + return path; + } + + /** + * Returns true if two pin kinds can be connected. + * Mirrors Picograph's type compatibility (any pin accepts `any`). + * + * @param {string} a + * @param {string} b + * @returns {boolean} + */ + #kindsCompatible(a, b) { + return a === b || a === "any" || b === "any"; + } +} diff --git a/website/scripts/PrintBubble.js b/website/scripts/PrintBubble.js new file mode 100644 index 0000000..a32a338 --- /dev/null +++ b/website/scripts/PrintBubble.js @@ -0,0 +1,71 @@ +/** + * Manages a Twitch-chat-style stacked speech bubble above a print node element. + * + * Messages enter at the bottom of the stack, push older messages upward, and + * individually fade out after 5 seconds — matching the Twitch chat feel. + */ +export class PrintBubble { + /** @type {HTMLElement} */ + #stack; + + /** Each active message and its cleanup timer. @type {Array<{ el: HTMLElement, timer: number }>} */ + #entries = []; + + static #FADE_START_MS = 4500; + static #FADE_DURATION_MS = 500; + + /** + * @param {HTMLElement} nodeEl - The node element to attach bubbles to. + */ + constructor(nodeEl) { + this.#stack = document.createElement("div"); + this.#stack.className = "print-bubble-stack"; + this.#stack.setAttribute("role", "log"); + this.#stack.setAttribute("aria-live", "polite"); + nodeEl.appendChild(this.#stack); + } + + /** + * Pushes a new message onto the bubble stack. + * + * @param {string} text - The message to display. + */ + push(text) { + const msg = document.createElement("div"); + msg.className = "print-bubble-msg"; + msg.textContent = String(text); + this.#stack.appendChild(msg); + + const entry = { el: msg, timer: 0 }; + this.#entries.push(entry); + + entry.timer = window.setTimeout(() => { + this.#fadeOut(entry); + }, PrintBubble.#FADE_START_MS); + } + + /** + * Triggers the fade-out animation and removes the message element. + * + * @param {{ el: HTMLElement, timer: number }} entry + */ + #fadeOut(entry) { + entry.el.classList.add("is-fading"); + window.setTimeout(() => { + entry.el.remove(); + const idx = this.#entries.indexOf(entry); + if (idx !== -1) this.#entries.splice(idx, 1); + }, PrintBubble.#FADE_DURATION_MS); + } + + /** + * Immediately clears all active messages and their timers. + */ + clear() { + for (const entry of this.#entries) { + window.clearTimeout(entry.timer); + entry.el.remove(); + } + this.#entries = []; + } +} diff --git a/website/scripts/PropertiesPanel.js b/website/scripts/PropertiesPanel.js new file mode 100644 index 0000000..8b8317b --- /dev/null +++ b/website/scripts/PropertiesPanel.js @@ -0,0 +1,386 @@ +/** + * Properties Panel - Visual editor for nodes, focus options, and graph settings + * Similar to Photoshop's properties panel + */ +export class PropertiesPanel { + /** @type {HTMLElement} */ + #panel; + + /** @type {(() => void) | null} */ + #onChange = null; + + /** @type {any} */ + #currentNode = null; + + /** @type {any} */ + #graphData = null; + + /** + * @param {HTMLElement} panel + * @param {(() => void) | null} [onChange] + */ + constructor(panel, onChange = null) { + this.#panel = panel; + this.#onChange = onChange; + + this.#bindEvents(); + } + + #bindEvents() { + // Toggle button (close) + const toggleBtn = this.#panel.querySelector('.properties-toggle-btn'); + toggleBtn?.addEventListener('click', () => { + this.#panel.classList.toggle('collapsed'); + }); + + // Tab button (open when collapsed) + const tabBtn = this.#panel.querySelector('.properties-panel-tab'); + tabBtn?.addEventListener('click', () => { + this.#panel.classList.remove('collapsed'); + }); + + // Collapsible sections + this.#panel.querySelectorAll('.section-header.collapsible').forEach(header => { + header.addEventListener('click', () => { + header.classList.toggle('collapsed'); + }); + }); + + // Node property inputs + document.getElementById('propNodeTitle')?.addEventListener('input', () => this.#updateNodeProperty('title')); + document.getElementById('propNodeX')?.addEventListener('input', () => this.#updateNodeProperty('x')); + document.getElementById('propNodeY')?.addEventListener('input', () => this.#updateNodeProperty('y')); + document.getElementById('propNodeWidth')?.addEventListener('input', () => this.#updateNodeProperty('width')); + + // Focus options inputs + document.getElementById('propFocusDuration')?.addEventListener('input', () => this.#updateFocusOptions()); + document.getElementById('propFocusAnchorX')?.addEventListener('input', () => this.#updateFocusOptions()); + document.getElementById('propFocusAnchorY')?.addEventListener('input', () => this.#updateFocusOptions()); + document.getElementById('propWorldBoxWidth')?.addEventListener('input', () => this.#updateFocusOptions()); + document.getElementById('propWorldBoxHeight')?.addEventListener('input', () => this.#updateFocusOptions()); + + // Add breakpoint button + document.getElementById('addBreakpointBtn')?.addEventListener('click', () => this.#addBreakpoint()); + + // Apply settings button + document.getElementById('applySettingsBtn')?.addEventListener('click', () => this.#applyGraphSettings()); + + // Graph settings + document.getElementById('propGraphDraggable')?.addEventListener('change', () => { + if (this.#graphData?.settings) { + this.#graphData.settings.draggable = document.getElementById('propGraphDraggable').checked; + this.#onChange?.(); + } + }); + } + + /** + * Load a node into the properties panel + * + * @param {any} node + */ + loadNode(node) { + this.#currentNode = node; + + if (!node) { + document.getElementById('nodePropertiesSection').style.display = 'none'; + document.getElementById('focusOptionsSection').style.display = 'none'; + document.getElementById('noSelectionMessage').style.display = 'block'; + return; + } + + document.getElementById('nodePropertiesSection').style.display = 'block'; + document.getElementById('focusOptionsSection').style.display = 'block'; + document.getElementById('noSelectionMessage').style.display = 'none'; + + // Populate node properties + const idInput = document.getElementById('propNodeId'); + const typeInput = document.getElementById('propNodeType'); + const titleInput = document.getElementById('propNodeTitle'); + const xInput = document.getElementById('propNodeX'); + const yInput = document.getElementById('propNodeY'); + const widthInput = document.getElementById('propNodeWidth'); + + if (idInput) idInput.value = node.id || ''; + if (typeInput) typeInput.value = node.type || ''; + if (titleInput) titleInput.value = node.title || ''; + if (xInput) xInput.value = node.position?.x ?? 0; + if (yInput) yInput.value = node.position?.y ?? 0; + if (widthInput) widthInput.value = node.width || ''; + + // Populate focus options + const focusOpts = node.focusOptions || {}; + const durationInput = document.getElementById('propFocusDuration'); + const anchorXInput = document.getElementById('propFocusAnchorX'); + const anchorYInput = document.getElementById('propFocusAnchorY'); + const boxWidthInput = document.getElementById('propWorldBoxWidth'); + const boxHeightInput = document.getElementById('propWorldBoxHeight'); + + if (durationInput) durationInput.value = focusOpts.durationMs || ''; + if (anchorXInput) anchorXInput.value = focusOpts.anchorX ?? ''; + if (anchorYInput) anchorYInput.value = focusOpts.anchorY ?? ''; + if (boxWidthInput) boxWidthInput.value = focusOpts.minWorldBox?.width || ''; + if (boxHeightInput) boxHeightInput.value = focusOpts.minWorldBox?.height || ''; + + // Render breakpoints + this.#renderBreakpoints(); + } + + /** + * Load graph settings + * + * @param {any} graphData + */ + loadGraphSettings(graphData) { + this.#graphData = graphData; + + const draggableCheck = document.getElementById('propGraphDraggable'); + const introDurationInput = document.getElementById('propIntroDuration'); + const viewXInput = document.getElementById('propInitViewX'); + const viewYInput = document.getElementById('propInitViewY'); + const viewWidthInput = document.getElementById('propInitViewWidth'); + const viewHeightInput = document.getElementById('propInitViewHeight'); + + const settings = graphData?.settings || {}; + const viewbox = settings.initialViewbox || {}; + + if (draggableCheck) draggableCheck.checked = settings.draggable ?? true; + if (introDurationInput) introDurationInput.value = settings.introDurationMs || ''; + if (viewXInput) viewXInput.value = viewbox.x || ''; + if (viewYInput) viewYInput.value = viewbox.y || ''; + if (viewWidthInput) viewWidthInput.value = viewbox.width || ''; + if (viewHeightInput) viewHeightInput.value = viewbox.height || ''; + } + + #updateNodeProperty(prop) { + if (!this.#currentNode) return; + + const titleInput = document.getElementById('propNodeTitle'); + const xInput = document.getElementById('propNodeX'); + const yInput = document.getElementById('propNodeY'); + const widthInput = document.getElementById('propNodeWidth'); + + if (prop === 'title' && titleInput) { + this.#currentNode.title = titleInput.value; + } else if (prop === 'x' && xInput) { + this.#currentNode.position.x = parseFloat(xInput.value) || 0; + } else if (prop === 'y' && yInput) { + this.#currentNode.position.y = parseFloat(yInput.value) || 0; + } else if (prop === 'width' && widthInput) { + this.#currentNode.width = widthInput.value ? parseFloat(widthInput.value) : undefined; + } + + this.#onChange?.(); + } + + #updateFocusOptions() { + if (!this.#currentNode) return; + + if (!this.#currentNode.focusOptions) { + this.#currentNode.focusOptions = {}; + } + + const durationInput = document.getElementById('propFocusDuration'); + const anchorXInput = document.getElementById('propFocusAnchorX'); + const anchorYInput = document.getElementById('propFocusAnchorY'); + const boxWidthInput = document.getElementById('propWorldBoxWidth'); + const boxHeightInput = document.getElementById('propWorldBoxHeight'); + + if (durationInput?.value) { + this.#currentNode.focusOptions.durationMs = parseFloat(durationInput.value); + } + if (anchorXInput?.value) { + this.#currentNode.focusOptions.anchorX = parseFloat(anchorXInput.value); + } + if (anchorYInput?.value) { + this.#currentNode.focusOptions.anchorY = parseFloat(anchorYInput.value); + } + + if (boxWidthInput?.value || boxHeightInput?.value) { + if (!this.#currentNode.focusOptions.minWorldBox) { + this.#currentNode.focusOptions.minWorldBox = {}; + } + if (boxWidthInput?.value) { + this.#currentNode.focusOptions.minWorldBox.width = parseFloat(boxWidthInput.value); + } + if (boxHeightInput?.value) { + this.#currentNode.focusOptions.minWorldBox.height = parseFloat(boxHeightInput.value); + } + } + + this.#onChange?.(); + } + + #renderBreakpoints() { + const container = document.getElementById('breakpointsList'); + if (!container) return; + + container.innerHTML = ''; + + const responsiveBoxes = this.#currentNode?.focusOptions?.responsiveWorldBox; + if (!responsiveBoxes) return; + + const breakpoints = Array.isArray(responsiveBoxes) ? responsiveBoxes : [responsiveBoxes]; + + breakpoints.forEach((bp, index) => { + const item = document.createElement('div'); + item.className = 'breakpoint-item'; + item.innerHTML = ` +
    +
    Breakpoint ${index + 1}
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + `; + container.appendChild(item); + + // Bind events + item.querySelector('.breakpoint-remove')?.addEventListener('click', () => this.#removeBreakpoint(index)); + item.querySelectorAll('input').forEach(input => { + input.addEventListener('input', () => this.#updateBreakpoint(index)); + }); + }); + } + + #addBreakpoint() { + if (!this.#currentNode) return; + + if (!this.#currentNode.focusOptions) { + this.#currentNode.focusOptions = {}; + } + + if (!this.#currentNode.focusOptions.responsiveWorldBox) { + this.#currentNode.focusOptions.responsiveWorldBox = []; + } else if (!Array.isArray(this.#currentNode.focusOptions.responsiveWorldBox)) { + this.#currentNode.focusOptions.responsiveWorldBox = [this.#currentNode.focusOptions.responsiveWorldBox]; + } + + this.#currentNode.focusOptions.responsiveWorldBox.push({ + minViewportWidth: 768, + minWorldBox: { width: 800, height: 600 }, + anchorX: 0.5, + anchorY: 0.5 + }); + + this.#renderBreakpoints(); + this.#onChange?.(); + } + + #removeBreakpoint(index) { + if (!this.#currentNode?.focusOptions?.responsiveWorldBox) return; + + const breakpoints = Array.isArray(this.#currentNode.focusOptions.responsiveWorldBox) + ? this.#currentNode.focusOptions.responsiveWorldBox + : [this.#currentNode.focusOptions.responsiveWorldBox]; + + breakpoints.splice(index, 1); + + if (breakpoints.length === 0) { + delete this.#currentNode.focusOptions.responsiveWorldBox; + } else { + this.#currentNode.focusOptions.responsiveWorldBox = breakpoints; + } + + this.#renderBreakpoints(); + this.#onChange?.(); + } + + #updateBreakpoint(index) { + if (!this.#currentNode?.focusOptions?.responsiveWorldBox) return; + + const breakpoints = Array.isArray(this.#currentNode.focusOptions.responsiveWorldBox) + ? this.#currentNode.focusOptions.responsiveWorldBox + : [this.#currentNode.focusOptions.responsiveWorldBox]; + + const bp = breakpoints[index]; + if (!bp) return; + + const minWidthInput = document.querySelector(`.bp-min-width[data-index="${index}"]`); + const boxWidthInput = document.querySelector(`.bp-box-width[data-index="${index}"]`); + const boxHeightInput = document.querySelector(`.bp-box-height[data-index="${index}"]`); + const anchorXInput = document.querySelector(`.bp-anchor-x[data-index="${index}"]`); + const anchorYInput = document.querySelector(`.bp-anchor-y[data-index="${index}"]`); + + if (minWidthInput?.value) bp.minViewportWidth = parseFloat(minWidthInput.value); + + if (!bp.minWorldBox) bp.minWorldBox = {}; + if (boxWidthInput?.value) bp.minWorldBox.width = parseFloat(boxWidthInput.value); + if (boxHeightInput?.value) bp.minWorldBox.height = parseFloat(boxHeightInput.value); + + if (anchorXInput?.value) bp.anchorX = parseFloat(anchorXInput.value); + if (anchorYInput?.value) bp.anchorY = parseFloat(anchorYInput.value); + + this.#onChange?.(); + } + + #applyGraphSettings() { + if (!this.#graphData) return; + + const introDurationInput = document.getElementById('propIntroDuration'); + const viewXInput = document.getElementById('propInitViewX'); + const viewYInput = document.getElementById('propInitViewY'); + const viewWidthInput = document.getElementById('propInitViewWidth'); + const viewHeightInput = document.getElementById('propInitViewHeight'); + + if (!this.#graphData.settings) this.#graphData.settings = {}; + if (!this.#graphData.settings.initialViewbox) this.#graphData.settings.initialViewbox = {}; + + if (introDurationInput?.value) { + this.#graphData.settings.introDurationMs = parseFloat(introDurationInput.value); + } + + const viewbox = this.#graphData.settings.initialViewbox; + if (viewXInput?.value) viewbox.x = parseFloat(viewXInput.value); + if (viewYInput?.value) viewbox.y = parseFloat(viewYInput.value); + if (viewWidthInput?.value) viewbox.width = parseFloat(viewWidthInput.value); + if (viewHeightInput?.value) viewbox.height = parseFloat(viewHeightInput.value); + + this.#onChange?.(); + + // Show confirmation + const btn = document.getElementById('applySettingsBtn'); + if (btn) { + const originalText = btn.textContent; + btn.textContent = 'Applied ✓'; + btn.style.background = 'rgba(60, 180, 100, 1)'; + setTimeout(() => { + btn.textContent = originalText; + btn.style.background = ''; + }, 1500); + } + } + + /** @returns {boolean} */ + isCollapsed() { + return this.#panel.classList.contains('collapsed'); + } + + /** Collapse the panel */ + collapse() { + this.#panel.classList.add('collapsed'); + } + + /** Expand the panel */ + expand() { + this.#panel.classList.remove('collapsed'); + } +} diff --git a/website/scripts/SpatialAudio.js b/website/scripts/SpatialAudio.js new file mode 100644 index 0000000..883d750 --- /dev/null +++ b/website/scripts/SpatialAudio.js @@ -0,0 +1,120 @@ +/** + * Manages spatial audio playback with distance-based volume and stereo panning. + */ +export class SpatialAudio { + /** @type {AudioContext} */ + #audioContext; + + /** @type {Map} */ + #buffers = new Map(); + + /** @type {() => {x: number, y: number, width: number, height: number}} */ + #getViewport; + + /** + * @param {() => {x: number, y: number, width: number, height: number}} getViewport - Returns current viewport bounds in world-space + */ + constructor(getViewport) { + this.#audioContext = new (window.AudioContext || window.webkitAudioContext)(); + this.#getViewport = getViewport; + } + + /** + * Preloads an audio file. + * + * @param {string} key - Identifier for this audio + * @param {string} url - Path to audio file + */ + async load(key, url) { + try { + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + const audioBuffer = await this.#audioContext.decodeAudioData(arrayBuffer); + this.#buffers.set(key, audioBuffer); + } catch (err) { + console.error(`[SpatialAudio] Failed to load "${url}":`, err); + } + } + + /** + * Plays a sound at a world-space position with spatial audio. + * Volume is based on how much of the viewport the source occupies. + * Sounds muffle progressively as the source moves off-screen. + * + * @param {string} key - Audio identifier + * @param {number} worldX - X position in world-space (anchor center) + * @param {number} worldY - Y position in world-space (anchor center) + * @param {number} worldWidth - Width in world-space + * @param {number} worldHeight - Height in world-space + * @param {{ minVolume?: number, maxVolume?: number, sizeThreshold?: number }} [options] + */ + play(key, worldX, worldY, worldWidth, worldHeight, options = {}) { + const buffer = this.#buffers.get(key); + if (!buffer) { + console.warn(`[SpatialAudio] Audio "${key}" not loaded`); + return; + } + + const viewport = this.#getViewport(); + + // Calculate what percentage of viewport the source occupies + const viewportArea = viewport.width * viewport.height; + const sourceArea = worldWidth * worldHeight; + const areaPercentage = sourceArea / viewportArea; + + // Volume based on size percentage (larger on screen = louder) + const minVolume = options.minVolume ?? 0; + const maxVolume = options.maxVolume ?? 1.0; + const sizeThreshold = options.sizeThreshold ?? 0.05; + + const sizeFactor = Math.min(1, areaPercentage / sizeThreshold); + const volume = minVolume + (maxVolume - minVolume) * sizeFactor; + + // Viewport intersection — how much of the node is actually visible (0–1) + const nodeLeft = worldX - worldWidth / 2; + const nodeTop = worldY - worldHeight / 2; + const nodeRight = worldX + worldWidth / 2; + const nodeBottom = worldY + worldHeight / 2; + + const overlapW = Math.max(0, Math.min(nodeRight, viewport.x + viewport.width) - Math.max(nodeLeft, viewport.x)); + const overlapH = Math.max(0, Math.min(nodeBottom, viewport.y + viewport.height) - Math.max(nodeTop, viewport.y)); + const overlapArea = overlapW * overlapH; + const visibilityFraction = sourceArea > 0 ? Math.min(1, overlapArea / sourceArea) : 0; + + // Stereo panning based on horizontal position relative to viewport centre + const centerX = viewport.x + viewport.width / 2; + const dx = worldX - centerX; + const panRange = viewport.width / 2; + const pan = Math.max(-1, Math.min(1, dx / panRange)); + + // Muffling: low-pass filter whose cutoff drops as the node leaves the screen. + // Fully on-screen → 20 000 Hz (effectively open) + // Fully off-screen → 400 Hz (thick muffle) + const MUFFLE_OPEN = 20000; + const MUFFLE_CLOSED = 300; + const cutoff = MUFFLE_CLOSED + (MUFFLE_OPEN - MUFFLE_CLOSED) * visibilityFraction; + + // Create audio nodes + const source = this.#audioContext.createBufferSource(); + source.buffer = buffer; + + const gainNode = this.#audioContext.createGain(); + gainNode.gain.value = volume; + + const panNode = this.#audioContext.createStereoPanner(); + panNode.pan.value = pan; + + const filterNode = this.#audioContext.createBiquadFilter(); + filterNode.type = "lowpass"; + filterNode.frequency.value = cutoff; + filterNode.Q.value = 0.7; + + // Connect: source → filter → gain → pan → destination + source.connect(filterNode); + filterNode.connect(gainNode); + gainNode.connect(panNode); + panNode.connect(this.#audioContext.destination); + + source.start(0); + } +} diff --git a/website/scripts/WorkspaceNavigator.js b/website/scripts/WorkspaceNavigator.js new file mode 100644 index 0000000..a18a27e --- /dev/null +++ b/website/scripts/WorkspaceNavigator.js @@ -0,0 +1,802 @@ +/** + * Handles pan and zoom for the graph workspace canvas. + * Ported directly from WorkspaceNavigationManager in Picograph. + * Grid is CSS-driven; this class updates CSS custom properties and background position. + */ +export class WorkspaceNavigator { + /** @type {HTMLElement} */ + #canvas; + + /** @type {HTMLElement | null} */ + #worldLayer = null; + + /** @type {(() => void) | null} */ + #onAfterTransform = null; + + /** @type {{ x: number, y: number }} */ + #backgroundOffset = { x: 0, y: 0 }; + + /** @type {{ x: number, y: number }} */ + #pendingLayerOffset = { x: 0, y: 0 }; + + /** @type {number} */ + #zoomLevel = 1; + + /** @type {number} */ + #targetZoomLevel = 1; + + /** @type {{ min: number, max: number, step: number }} */ + #zoomConfig = { min: 0.25, max: 2.5, step: 0.1 }; + + /** @type {number | null} */ + #smoothZoomRafId = null; + + /** @type {number | null} */ + #focusAnimRafId = null; + + /** @type {number} */ + #focusAnimGen = 0; + + /** @type {{ screenPoint: { x: number, y: number }, pointer: { clientX?: number, clientY?: number } } | null} */ + #smoothZoomFocus = null; + + /** @type {{ pointerId: number, startX: number, startY: number, currentClientX: number, currentClientY: number, hasMoved: boolean, backgroundOrigin: { x: number, y: number }, lastDelta: { x: number, y: number } } | null} */ + #panState = null; + + /** @type {number} */ + #lastPanTimestamp = 0; + + /** @type {{ width: number, height: number }} */ + #lastCanvasSize = { width: 0, height: 0 }; + + /** @type {{ touches: Map, initialDistance: number, initialZoom: number, initialBackgroundOffset: {x: number, y: number}, center: {x: number, y: number} } | null} */ + #pinchState = null; + + /** @type {(() => boolean) | null} */ + #isDragInProgress = null; + + /** @type {(() => void) | null} */ + #onPinchStart = null; + + /** + * @param {HTMLElement} canvas - The `.workspace-canvas` element. + * @param {HTMLElement | null} [worldLayer] - Optional world-space layer to transform with pan/zoom. + * @param {(() => void) | null} [onAfterTransform] - Called after every transform update. + */ + constructor(canvas, worldLayer = null, onAfterTransform = null) { + this.#canvas = canvas; + this.#worldLayer = worldLayer; + this.#onAfterTransform = onAfterTransform; + const initialRect = canvas.getBoundingClientRect(); + this.#lastCanvasSize = { width: initialRect.width, height: initialRect.height }; + this.#updateZoomDisplay(); + this.#bindEvents(); + this.#bindResizeObserver(); + } + + /** + * Sets the callback fired after every transform update. + * Assign this AFTER constructing dependent renderers to avoid temporal dead zone issues. + * + * @param {(() => void) | null} cb + */ + setOnAfterTransform(cb) { + this.#onAfterTransform = cb; + } + + /** + * Registers a callback that returns true when a node drag is actively in progress. + * When true, single-finger touch and pointer pan will be suppressed so the drag + * can move the node without competing with the pan handler. + * + * @param {(() => boolean) | null} fn + */ + setDragInProgressChecker(fn) { + this.#isDragInProgress = fn; + } + + /** + * Registers a callback fired when a two-finger pinch gesture begins. + * Use this to cancel any active node drag before the zoom takes over. + * + * @param {(() => void) | null} fn + */ + setOnPinchStart(fn) { + this.#onPinchStart = fn; + } + + /** True while a two-finger pinch gesture is in progress. */ + get isPinching() { return this.#pinchState !== null; } + + /** + * Pans the viewport by a world-space delta. + * Used by ItemDragger's edge-pan loop to scroll while dragging a node. + * + * @param {number} worldDx + * @param {number} worldDy + */ + panBy(worldDx, worldDy) { + this.#setBackgroundOffset({ + x: this.#backgroundOffset.x + worldDx, + y: this.#backgroundOffset.y + worldDy, + }); + } + + // ─── Public ─────────────────────────────────────────────────────────────── + + /** Current zoom level. */ + get zoom() { return this.#zoomLevel; } + + /** @returns {number} */ + get zoomLevel() { return this.#zoomLevel; } + + /** + * Returns the total effective offset (backgroundOffset + pendingLayerOffset). + * Matches WorkspaceNavigationManager.getEffectiveOffset() in Picograph. + * + * @returns {{ x: number, y: number }} + */ + getEffectiveOffset() { + return { + x: this.#backgroundOffset.x + this.#pendingLayerOffset.x, + y: this.#backgroundOffset.y + this.#pendingLayerOffset.y, + }; + } + + /** Reset view to origin, zoom 1. */ + reset() { + if (this.#smoothZoomRafId !== null) { + cancelAnimationFrame(this.#smoothZoomRafId); + this.#smoothZoomRafId = null; + this.#smoothZoomFocus = null; + } + this.#backgroundOffset = { x: 0, y: 0 }; + this.#pendingLayerOffset = { x: 0, y: 0 }; + this.#zoomLevel = 1; + this.#targetZoomLevel = 1; + this.#updateZoomDisplay(); + } + + /** + * Animates the viewport to frame a world-space rect. + * Respects a minimum framing fraction so nodes never appear too small. + * + * @param {{ x: number, y: number, width: number, height: number }} worldRect World-space bounds to frame. + * @param {{ paddingFraction?: number, durationMs?: number, minWorldBox?: { width: number, height: number }, responsiveWorldBox?: { minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number } | Array<{ minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number }>, anchorX?: number, anchorY?: number }} [options] + * anchorX/anchorY are screen-space fractions (0–1) for where the rect centre lands. + * 0.5,0.5 = screen centre (default). 0.0,0.5 = left edge centre. 0.0,0.0 = top-left. + * responsiveWorldBox allows overriding minWorldBox, anchorX, anchorY based on viewport width. + * Can be a single breakpoint object or an array of breakpoints (evaluated largest to smallest). + */ + animateFocusOnRect(worldRect, options = {}) { + let { + paddingFraction = 0.0, + durationMs = 550, + minWorldBox = null, // { width, height } — minimum world-space area that must be visible + responsiveWorldBox = null, // { minViewportWidth, minWorldBox, anchorX?, anchorY? } — overrides when viewport is smaller + anchorX = 0.5, // screen-space X fraction where rect centre is placed + anchorY = 0.5, // screen-space Y fraction where rect centre is placed + } = options; + + // Cancel any ongoing smooth zoom and commit pending offset + if (this.#smoothZoomRafId !== null) { + cancelAnimationFrame(this.#smoothZoomRafId); + this.#smoothZoomRafId = null; + this.#smoothZoomFocus = null; + const pending = this.#pendingLayerOffset; + this.#pendingLayerOffset = { x: 0, y: 0 }; + if (Math.abs(pending.x) > 0.0001 || Math.abs(pending.y) > 0.0001) { + this.#setBackgroundOffset({ + x: this.#backgroundOffset.x + pending.x, + y: this.#backgroundOffset.y + pending.y, + }); + } + } + + const canvasRect = this.#canvas.getBoundingClientRect(); + const vw = canvasRect.width; + const vh = canvasRect.height; + + // Check if we should use responsive settings + if (responsiveWorldBox) { + const breakpoints = Array.isArray(responsiveWorldBox) ? responsiveWorldBox : [responsiveWorldBox]; + // Sort ascending — smallest threshold that still exceeds window width wins (most specific match) + const sorted = [...breakpoints].sort((a, b) => (a.minViewportWidth ?? 0) - (b.minViewportWidth ?? 0)); + + // Find first matching breakpoint where window is smaller than threshold + const windowWidth = window.innerWidth; + for (const bp of sorted) { + if (windowWidth < (bp.minViewportWidth ?? Infinity)) { + minWorldBox = bp.minWorldBox ?? minWorldBox; + anchorX = bp.anchorX ?? anchorX; + anchorY = bp.anchorY ?? anchorY; + break; + } + } + } + + const pad = Math.min(vw, vh) * paddingFraction; + const availW = Math.max(1, vw - pad * 2); + const availH = Math.max(1, vh - pad * 2); + + // The framing rect is the union of worldRect and the minWorldBox centred on it + let frameW = Math.max(1, worldRect.width); + let frameH = Math.max(1, worldRect.height); + + if (minWorldBox) { + // Expand frame to at least minWorldBox dimensions, centred on worldRect centre + frameW = Math.max(frameW, minWorldBox.width ?? 0); + frameH = Math.max(frameH, minWorldBox.height ?? 0); + } + + // Zoom to fit the frame rect inside the available viewport + let targetZoom = Math.min(availW / frameW, availH / frameH); + targetZoom = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, targetZoom)); + + // Calculate the target world center based on node and minWorldBox + const worldCx = worldRect.x + Math.max(1, worldRect.width) / 2; + const worldCy = worldRect.y + Math.max(1, worldRect.height) / 2; + + let targetWorldCenterX = worldCx; + let targetWorldCenterY = worldCy; + + if (minWorldBox) { + // When minWorldBox exists, anchor positions the node WITHIN the box. + // The box itself should be centered on screen (ignoring anchor for box positioning). + // Calculate worldbox bounds with node positioned at anchor within it + const boxX = worldCx - minWorldBox.width * anchorX; + const boxY = worldCy - minWorldBox.height * anchorY; + targetWorldCenterX = boxX + minWorldBox.width / 2; + targetWorldCenterY = boxY + minWorldBox.height / 2; + } + + // Position the target point at viewport center + const centerScreenX = pad + availW / 2; + const centerScreenY = pad + availH / 2; + const targetOffsetX = centerScreenX / targetZoom - targetWorldCenterX; + const targetOffsetY = centerScreenY / targetZoom - targetWorldCenterY; + + const fromZoom = this.#zoomLevel; + const fromOffsetX = this.#backgroundOffset.x; + const fromOffsetY = this.#backgroundOffset.y; + + const gen = ++this.#focusAnimGen; + const start = this.#timestamp(); + + const tick = () => { + if (this.#focusAnimGen !== gen) return; + const elapsed = this.#timestamp() - start; + const t = Math.min(elapsed / durationMs, 1); + const eased = 1 - Math.pow(1 - t, 3); + + this.#zoomLevel = fromZoom + (targetZoom - fromZoom) * eased; + this.#targetZoomLevel = this.#zoomLevel; + this.#setBackgroundOffset({ + x: fromOffsetX + (targetOffsetX - fromOffsetX) * eased, + y: fromOffsetY + (targetOffsetY - fromOffsetY) * eased, + }); + this.#updateZoomDisplay(); + + if (t < 1) { + this.#focusAnimRafId = requestAnimationFrame(tick); + } else { + this.#focusAnimRafId = null; + this.#zoomLevel = targetZoom; + this.#targetZoomLevel = targetZoom; + } + }; + this.#focusAnimRafId = requestAnimationFrame(tick); + } + + /** + * Instantly fits a world-space rect into the viewport with optional padding. + * The zoom is chosen to fit the rect; aspect-ratio mismatches are letter-boxed. + * + * @param {{ x: number, y: number, width: number, height: number }} worldRect + * @param {{ paddingFraction?: number }} [options] + */ + focusOnRect(worldRect, { paddingFraction = 0, anchorX = 0.5, anchorY = 0.5 } = {}) { + if (this.#smoothZoomRafId !== null) { + cancelAnimationFrame(this.#smoothZoomRafId); + this.#smoothZoomRafId = null; + this.#smoothZoomFocus = null; + } + if (this.#focusAnimRafId !== null) { + cancelAnimationFrame(this.#focusAnimRafId); + this.#focusAnimRafId = null; + this.#focusAnimGen++; + } + this.#pendingLayerOffset = { x: 0, y: 0 }; + const canvas = this.#canvas.getBoundingClientRect(); + const vw = canvas.width; + const vh = canvas.height; + const pad = Math.min(vw, vh) * paddingFraction; + const availW = Math.max(1, vw - pad * 2); + const availH = Math.max(1, vh - pad * 2); + const zoom = Math.max( + this.#zoomConfig.min, + Math.min(this.#zoomConfig.max, + Math.min(availW / Math.max(1, worldRect.width), availH / Math.max(1, worldRect.height)) + ) + ); + const centerScreenX = pad + anchorX * availW; + const centerScreenY = pad + anchorY * availH; + const cx = worldRect.x + worldRect.width / 2; + const cy = worldRect.y + worldRect.height / 2; + this.#zoomLevel = zoom; + this.#targetZoomLevel = zoom; + this.#setBackgroundOffset({ + x: centerScreenX / zoom - cx, + y: centerScreenY / zoom - cy, + }); + this.#updateZoomDisplay(); + } + + /** + * Immediately centres the viewport on a world-space point at the given zoom. + * @param {{ x: number, y: number }} worldPoint World-space coordinates to centre on. + * @param {number} [zoom] Zoom level to snap to. + */ + focusOnWorld(worldPoint, zoom = 1) { + if (this.#smoothZoomRafId !== null) { + cancelAnimationFrame(this.#smoothZoomRafId); + this.#smoothZoomRafId = null; + this.#smoothZoomFocus = null; + } + this.#pendingLayerOffset = { x: 0, y: 0 }; + const clamped = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, zoom)); + this.#zoomLevel = clamped; + this.#targetZoomLevel = clamped; + const rect = this.#canvas.getBoundingClientRect(); + this.#setBackgroundOffset({ + x: rect.width / 2 / clamped - worldPoint.x, + y: rect.height / 2 / clamped - worldPoint.y, + }); + this.#updateZoomDisplay(); + } + + /** + * Returns the current viewport info in world space. + * + * @returns {{ zoom: number, center: { x: number, y: number }, viewbox: { x: number, y: number, width: number, height: number } }} + */ + getViewInfo() { + const rect = this.#canvas.getBoundingClientRect(); + const zoom = this.#zoomLevel || 1; + const eff = this.getEffectiveOffset(); + const vx = -eff.x; + const vy = -eff.y; + const vw = rect.width / zoom; + const vh = rect.height / zoom; + return { + zoom, + viewbox: { x: vx, y: vy, width: vw, height: vh }, + center: { x: vx + vw / 2, y: vy + vh / 2 }, + }; + } + + // ─── Events ─────────────────────────────────────────────────────────────── + + #bindResizeObserver() { + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { inlineSize: newW, blockSize: newH } = entry.contentBoxSize[0]; + const dw = newW - this.#lastCanvasSize.width; + const dh = newH - this.#lastCanvasSize.height; + this.#lastCanvasSize = { width: newW, height: newH }; + if (Math.abs(dw) < 0.5 && Math.abs(dh) < 0.5) continue; + const zoom = this.#zoomLevel || 1; + this.#setBackgroundOffset({ + x: this.#backgroundOffset.x + dw / 2 / zoom, + y: this.#backgroundOffset.y + dh / 2 / zoom, + }); + this.#updateZoomDisplay(); + } + }); + observer.observe(this.#canvas); + } + + #bindEvents() { + this.#canvas.addEventListener("wheel", (e) => this.#onWheel(e), { passive: false }); + this.#canvas.addEventListener("pointerdown", (e) => this.#onPointerDown(e)); + this.#canvas.addEventListener("touchstart", (e) => this.#onTouchStart(e), { passive: false }); + this.#canvas.addEventListener("touchmove", (e) => this.#onTouchMove(e), { passive: false }); + this.#canvas.addEventListener("touchend", (e) => this.#onTouchEnd(e), { passive: false }); + this.#canvas.addEventListener("touchcancel", (e) => this.#onTouchEnd(e), { passive: false }); + this.#canvas.addEventListener("contextmenu", (e) => { + // Suppress context menu if pan just ended (matches original 200ms threshold) + if (this.#timestamp() - this.#lastPanTimestamp < 200) { + e.preventDefault(); + return; + } + e.preventDefault(); + }); + } + + /** @param {WheelEvent} e */ + #onWheel(e) { + if (!Number.isFinite(e.deltaY) || e.deltaY === 0) return; + + const rect = this.#canvas.getBoundingClientRect(); + const screenPoint = { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }; + + if (screenPoint.x < 0 || screenPoint.y < 0 || screenPoint.x > rect.width || screenPoint.y > rect.height) return; + + e.preventDefault(); + const direction = e.deltaY < 0 ? 1 : -1; + this.#pushSmoothZoom(screenPoint, direction, { clientX: e.clientX, clientY: e.clientY }); + } + + /** @param {PointerEvent} e */ + #onPointerDown(e) { + if (e.button !== 0 && e.button !== 2) return; + this.#beginPan(e); + } + + // ─── Touch (Pinch-to-Zoom) ──────────────────────────────────────────────── + + /** @param {TouchEvent} e */ + #onTouchStart(e) { + if (e.touches.length === 2) { + e.preventDefault(); + // Drop any active node drag before pinch-zoom takes control + this.#onPinchStart?.(); + const rect = this.#canvas.getBoundingClientRect(); + const t1 = e.touches[0]; + const t2 = e.touches[1]; + + const p1 = { x: t1.clientX - rect.left, y: t1.clientY - rect.top }; + const p2 = { x: t2.clientX - rect.left, y: t2.clientY - rect.top }; + + const distance = Math.hypot(p2.x - p1.x, p2.y - p1.y); + const center = { + x: (p1.x + p2.x) / 2, + y: (p1.y + p2.y) / 2 + }; + + this.#pinchState = { + touches: new Map([ + [t1.identifier, p1], + [t2.identifier, p2] + ]), + initialDistance: distance, + initialZoom: this.#zoomLevel, + initialBackgroundOffset: { ...this.#backgroundOffset }, + center: center + }; + + // Cancel pan state when pinch starts + this.#panState = null; + } else if (e.touches.length === 1 && !this.#panState) { + // If a node drag is in progress, suppress single-finger pan so the node moves instead + if (this.#isDragInProgress?.()) return; + // Single finger pan - only if not already panning + const touch = e.touches[0]; + this.#panState = { + pointerId: touch.identifier, + startX: touch.clientX, + startY: touch.clientY, + currentClientX: touch.clientX, + currentClientY: touch.clientY, + hasMoved: false, + backgroundOrigin: { ...this.#backgroundOffset }, + lastDelta: { x: 0, y: 0 }, + }; + } + } + + /** @param {TouchEvent} e */ + #onTouchMove(e) { + // Handle two-finger pinch + pan + if (this.#pinchState && e.touches.length === 2) { + e.preventDefault(); + const rect = this.#canvas.getBoundingClientRect(); + const t1 = e.touches[0]; + const t2 = e.touches[1]; + + const p1 = { x: t1.clientX - rect.left, y: t1.clientY - rect.top }; + const p2 = { x: t2.clientX - rect.left, y: t2.clientY - rect.top }; + + // Calculate current distance for zoom + const currentDistance = Math.hypot(p2.x - p1.x, p2.y - p1.y); + const scale = currentDistance / this.#pinchState.initialDistance; + + let newZoom = this.#pinchState.initialZoom * scale; + newZoom = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, newZoom)); + + // Calculate current center for pan + const currentCenter = { + x: (p1.x + p2.x) / 2, + y: (p1.y + p2.y) / 2 + }; + + // Pan delta in screen space + const panDeltaScreen = { + x: currentCenter.x - this.#pinchState.center.x, + y: currentCenter.y - this.#pinchState.center.y + }; + + const previousZoom = this.#zoomLevel; + const initialCenter = this.#pinchState.center; + + // World point under initial pinch center (using initial background offset) + const worldPoint = { + x: initialCenter.x / this.#pinchState.initialZoom - this.#pinchState.initialBackgroundOffset.x, + y: initialCenter.y / this.#pinchState.initialZoom - this.#pinchState.initialBackgroundOffset.y + }; + + this.#zoomLevel = newZoom; + this.#targetZoomLevel = newZoom; + + // Adjust offset to keep world point under pinch center AND apply pan + this.#setBackgroundOffset({ + x: initialCenter.x / newZoom - worldPoint.x + panDeltaScreen.x / newZoom, + y: initialCenter.y / newZoom - worldPoint.y + panDeltaScreen.y / newZoom + }); + + this.#updateZoomDisplay(); + return; + } + + // Handle single-finger pan + if (this.#panState && e.touches.length === 1) { + const touch = e.touches[0]; + + this.#panState.currentClientX = touch.clientX; + this.#panState.currentClientY = touch.clientY; + + const deltaXScreen = touch.clientX - this.#panState.startX; + const deltaYScreen = touch.clientY - this.#panState.startY; + const zoom = this.#zoomLevel || 1; + const worldDelta = { x: deltaXScreen / zoom, y: deltaYScreen / zoom }; + + if (!this.#panState.hasMoved) { + if (Math.hypot(deltaXScreen, deltaYScreen) < 3) return; + e.preventDefault(); + this.#panState.hasMoved = true; + } + + if (this.#panState.hasMoved) { + e.preventDefault(); + } + + this.#panState.lastDelta = worldDelta; + this.#setBackgroundOffset({ + x: this.#panState.backgroundOrigin.x + worldDelta.x, + y: this.#panState.backgroundOrigin.y + worldDelta.y, + }); + this.#updateZoomDisplay(); + } + } + + /** @param {TouchEvent} e */ + #onTouchEnd(e) { + // Clear pinch state if we go below 2 touches + if (this.#pinchState && e.touches.length < 2) { + this.#pinchState = null; + } + + // Clear pan state if no touches remain + if (this.#panState && e.touches.length === 0) { + const finalState = this.#panState; + this.#panState = null; + if (finalState.hasMoved) { + this.#lastPanTimestamp = this.#timestamp(); + } + } + } + + // ─── Pan ───────────────────────────────────────────────────────────────── + + /** @param {PointerEvent} event */ + #beginPan(event) { + if (this.#panState || this.#pinchState) return; + // Do not start a pan while a node drag is actively in progress + if (this.#isDragInProgress?.()) return; + + const state = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + currentClientX: event.clientX, + currentClientY: event.clientY, + hasMoved: false, + backgroundOrigin: { ...this.#backgroundOffset }, + lastDelta: { x: 0, y: 0 }, + }; + + /** @param {PointerEvent} moveEvent */ + const handlePointerMove = (moveEvent) => { + if (!this.#panState || moveEvent.pointerId !== this.#panState.pointerId) return; + + this.#panState.currentClientX = moveEvent.clientX; + this.#panState.currentClientY = moveEvent.clientY; + + const deltaXScreen = moveEvent.clientX - this.#panState.startX; + const deltaYScreen = moveEvent.clientY - this.#panState.startY; + const zoom = this.#zoomLevel || 1; + const worldDelta = { x: deltaXScreen / zoom, y: deltaYScreen / zoom }; + + if (!this.#panState.hasMoved) { + if (Math.hypot(deltaXScreen, deltaYScreen) < 3) return; + moveEvent.preventDefault(); + this.#panState.hasMoved = true; + } + + this.#panState.lastDelta = worldDelta; + this.#setBackgroundOffset({ + x: this.#panState.backgroundOrigin.x + worldDelta.x, + y: this.#panState.backgroundOrigin.y + worldDelta.y, + }); + this.#updateZoomDisplay(); + }; + + /** @param {PointerEvent} upEvent */ + const handlePointerUp = (upEvent) => { + if (!this.#panState || upEvent.pointerId !== this.#panState.pointerId) return; + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + const finalState = this.#panState; + this.#panState = null; + if (finalState.hasMoved) { + this.#lastPanTimestamp = this.#timestamp(); + } + }; + + this.#panState = state; + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + } + + // ─── Smooth Zoom ────────────────────────────────────────────────────────── + + /** + * @param {{ x: number, y: number }} screenPoint + * @param {number} direction + * @param {{ clientX?: number, clientY?: number }} [pointer] + */ + #pushSmoothZoom(screenPoint, direction, pointer = {}) { + if (!direction) return; + // Cancel any in-progress rect focus animation + if (this.#focusAnimRafId !== null) { + cancelAnimationFrame(this.#focusAnimRafId); + this.#focusAnimRafId = null; + this.#focusAnimGen++; + } + const step = direction * this.#zoomConfig.step; + this.#targetZoomLevel = Math.max( + this.#zoomConfig.min, + Math.min(this.#zoomConfig.max, this.#targetZoomLevel + step) + ); + this.#smoothZoomFocus = { screenPoint, pointer }; + if (this.#smoothZoomRafId === null) { + this.#smoothZoomRafId = requestAnimationFrame(() => this.#tickSmoothZoom()); + } + } + + #tickSmoothZoom() { + this.#smoothZoomRafId = null; + if (!this.#smoothZoomFocus) return; + + const target = this.#targetZoomLevel; + const current = this.#zoomLevel; + const diff = target - current; + const settled = Math.abs(diff) < 0.001; + const newZoom = settled + ? target + : Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, current + diff * 0.25)); + + const { screenPoint } = this.#smoothZoomFocus; + const previousZoom = current; + + // World point under cursor (using old zoom) + const worldPoint = { + x: screenPoint.x / previousZoom, + y: screenPoint.y / previousZoom, + }; + + this.#zoomLevel = newZoom; + + const scale = previousZoom / newZoom; + const shift = { + x: worldPoint.x * (scale - 1), + y: worldPoint.y * (scale - 1), + }; + + this.#pendingLayerOffset.x += shift.x; + this.#pendingLayerOffset.y += shift.y; + + // Rebase active pan so world deltas stay stable mid-zoom + if (Math.abs(shift.x) > 0.0001 || Math.abs(shift.y) > 0.0001) { + this.#rebasePanAfterZoom(this.#smoothZoomFocus.pointer); + } + + this.#canvas.style.setProperty("--workspace-zoom", `${newZoom}`); + this.#applyBackgroundOffset(); + + if (settled) { + this.#smoothZoomFocus = null; + const finalOffset = this.#pendingLayerOffset; + this.#pendingLayerOffset = { x: 0, y: 0 }; + if (Math.abs(finalOffset.x) > 0.0001 || Math.abs(finalOffset.y) > 0.0001) { + // Commit pivot into backgroundOffset (no nodes to translate on the website) + this.#setBackgroundOffset({ + x: this.#backgroundOffset.x + finalOffset.x, + y: this.#backgroundOffset.y + finalOffset.y, + }); + // Also rebase pan origin so pan doesn't jump + if (this.#panState) { + this.#panState.backgroundOrigin.x += finalOffset.x; + this.#panState.backgroundOrigin.y += finalOffset.y; + } + } + this.#updateZoomDisplay(); + } else { + this.#smoothZoomRafId = requestAnimationFrame(() => this.#tickSmoothZoom()); + } + } + + // ─── Transform helpers ──────────────────────────────────────────────────── + + /** @param {{ x: number, y: number }} offset */ + #setBackgroundOffset(offset) { + this.#backgroundOffset = { + x: Number.isFinite(offset.x) ? offset.x : 0, + y: Number.isFinite(offset.y) ? offset.y : 0, + }; + this.#applyBackgroundOffset(); + } + + #applyBackgroundOffset() { + const zoom = this.#zoomLevel || 1; + const scaledX = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * zoom; + const scaledY = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * zoom; + const position = `${scaledX}px ${scaledY}px`; + this.#canvas.style.backgroundPosition = `${position}, ${position}, ${position}, ${position}`; + this.#applyWorldLayerTransform(); + } + + #updateZoomDisplay() { + const zoom = this.#zoomLevel || 1; + this.#canvas.style.setProperty("--workspace-zoom", `${zoom}`); + this.#applyBackgroundOffset(); // also calls #applyWorldLayerTransform + } + + #applyWorldLayerTransform() { + if (!this.#worldLayer) return; + const zoom = this.#zoomLevel || 1; + const tx = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * zoom; + const ty = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * zoom; + this.#worldLayer.style.transformOrigin = "0 0"; + this.#worldLayer.style.transform = `translate(${tx}px, ${ty}px) scale(${zoom})`; + this.#onAfterTransform?.(); + } + + /** @param {{ clientX?: number, clientY?: number }} [pointer] */ + #rebasePanAfterZoom(pointer) { + if (!this.#panState) return; + const zoom = this.#zoomLevel || 1; + const clientX = Number.isFinite(this.#panState.currentClientX) + ? this.#panState.currentClientX + : pointer?.clientX; + const clientY = Number.isFinite(this.#panState.currentClientY) + ? this.#panState.currentClientY + : pointer?.clientY; + if (Number.isFinite(clientX)) { + this.#panState.startX = clientX - this.#panState.lastDelta.x * zoom; + } + if (Number.isFinite(clientY)) { + this.#panState.startY = clientY - this.#panState.lastDelta.y * zoom; + } + } + + /** @returns {number} */ + #timestamp() { + return typeof performance !== "undefined" ? performance.now() : Date.now(); + } +} diff --git a/website/scripts/commands/about.js b/website/scripts/commands/about.js deleted file mode 100644 index 08cb30c..0000000 --- a/website/scripts/commands/about.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * About command - Information about this terminal - */ -export default { - description: 'About this terminal', - execute: (term, writeClickable, VERSION) => { - return [ - '', - '╔════════════════════════════════════════════════════════════╗', - '║ LIT.RUV.WTF TERMINAL ║', - '╚════════════════════════════════════════════════════════════╝', - '', - ' A classic terminal interface built with xterm.js', - ' Features: Keyboard navigation, Mouse support, CRT effects', - ' Version: ' + VERSION, - ' Built: ' + new Date().getFullYear(), - '', - ' Technologies:', - ' • xterm.js - Terminal emulator', - ' • JavaScript - Terminal logic', - ' • CSS3 - Classic CRT styling', - '' - ].join('\r\n'); - } -}; diff --git a/website/scripts/commands/banner.js b/website/scripts/commands/banner.js deleted file mode 100644 index 641b6cc..0000000 --- a/website/scripts/commands/banner.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Banner command - Display welcome banner - */ -export default { - description: 'Display welcome banner', - execute: (term, writeClickable, VERSION, args, commandHistory, welcomeBannerFull, welcomeBannerCompact, welcomeBannerMinimal) => { - const cols = term.cols; - if (cols >= 78) { - term.writeln(welcomeBannerFull.split('\r\n').slice(0, -3).join('\r\n')); - writeClickable(' Type [command=help] for available commands.'); - term.writeln(' Use ↑/↓ arrows to navigate command history.'); - term.writeln(''); - } else if (cols >= 40) { - term.writeln(welcomeBannerCompact.split('\r\n').slice(0, -2).join('\r\n')); - writeClickable(' Welcome! Type [command=help] for commands.'); - term.writeln(''); - } else { - term.writeln(welcomeBannerMinimal.split('\r\n').slice(0, -2).join('\r\n')); - writeClickable(' Type [command=help]'); - term.writeln(''); - } - return null; - } -}; diff --git a/website/scripts/commands/bluesky.js b/website/scripts/commands/bluesky.js deleted file mode 100644 index 410fb47..0000000 --- a/website/scripts/commands/bluesky.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Bluesky command - Fetch recent posts from Bluesky - */ -export default { - description: 'Fetch recent posts from Bluesky', - execute: async (term, writeClickable, VERSION, args) => { - const actor = 'lit.mates.dev'; - const limit = args[0] ? parseInt(args[0]) : 5; - - try { - term.writeln('\r\n Fetching posts from Bluesky...\r\n'); - - const response = await fetch( - `https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${actor}&limit=${limit}` - ); - - if (!response.ok) { - return ' Error: Unable to fetch Bluesky posts'; - } - - const data = await response.json(); - - if (!data.feed || data.feed.length === 0) { - return ' No posts found.'; - } - - let output = [' ╔════════════════════════════════════════════════════╗']; - output.push(' ║ BLUESKY POSTS - @lit.mates.dev ║'); - output.push(' ╚════════════════════════════════════════════════════╝'); - output.push(''); - - // Reverse to show oldest first, latest at bottom - const reversedFeed = [...data.feed].reverse(); - - reversedFeed.forEach((item, idx) => { - const post = item.post; - const text = post.record.text; - const createdAt = new Date(post.record.createdAt); - const date = createdAt.toLocaleDateString(); - const time = createdAt.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' - }); - - // Extract post ID from URI (at://did:plc:xxx/app.bsky.feed.post/{postId}) - const postId = post.uri.split('/').pop(); - const postUrl = `https://bsky.app/profile/${actor}/post/${postId}`; - - output.push(` [${idx + 1}] ${date} ${time}`); - output.push(` ${postUrl}`); - output.push(' ────────────────────────────────────────'); - - // Wrap text to max 50 chars - const words = text.split(' '); - let line = ' '; - words.forEach(word => { - if (line.length + word.length + 1 > 52) { - output.push(line); - line = ' ' + word; - } else { - line += (line.length > 2 ? ' ' : '') + word; - } - }); - if (line.length > 2) output.push(line); - - output.push(''); - output.push(` ♡ ${post.likeCount || 0} ↻ ${post.repostCount || 0} 💬 ${post.replyCount || 0}`); - output.push(''); - }); - - output.push(` Usage: bluesky [count] (default: 5, max: 20)`); - output.push(''); - - return output.join('\r\n'); - } catch (error) { - return ' Error: Failed to connect to Bluesky API'; - } - } -}; diff --git a/website/scripts/commands/clear.js b/website/scripts/commands/clear.js deleted file mode 100644 index e3e726d..0000000 --- a/website/scripts/commands/clear.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Clear command - Clear terminal screen - */ -export default { - description: 'Clear terminal screen', - execute: (term) => { - term.clear(); - return null; - } -}; diff --git a/website/scripts/commands/color.js b/website/scripts/commands/color.js deleted file mode 100644 index 25d4c73..0000000 --- a/website/scripts/commands/color.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Color command - Change color scheme - */ -export default { - description: 'Change color scheme', - execute: (term, writeClickable, VERSION, args) => { - const scheme = args[0] || ''; - const schemes = { - green: { bg: '#001800', fg: '#00ff00', border: '#0f0' }, - amber: { bg: '#1a0f00', fg: '#ffb000', border: '#ffb000' }, - blue: { bg: '#000818', fg: '#00a0ff', border: '#00a0ff' }, - white: { bg: '#0a0a0a', fg: '#e0e0e0', border: '#999' } - }; - - if (!scheme || !schemes[scheme]) { - return [ - '', - ' Available color schemes:', - ' • green - Classic green terminal', - ' • amber - Amber monochrome', - ' • blue - IBM blue', - ' • white - White phosphor', - '', - ' Usage: color [scheme]', - '' - ].join('\r\n'); - } - - const colors = schemes[scheme]; - term.options.theme.background = colors.bg; - term.options.theme.foreground = colors.fg; - document.querySelector('.container').style.borderColor = colors.border; - document.querySelector('.container').style.background = colors.bg; - document.body.style.color = colors.fg; - - return `\r\n Color scheme changed to: ${scheme}\r\n`; - } -}; diff --git a/website/scripts/commands/contact.js b/website/scripts/commands/contact.js deleted file mode 100644 index 478d0c2..0000000 --- a/website/scripts/commands/contact.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Contact command - Contact information - */ -export default { - description: 'Contact information', - execute: () => { - return [ - '', - ' Contact Information:', - ' ────────────────────', - ' Email: contact@lit.ruv.wtf', - ' Web: https://lit.ruv.wtf', - '' - ].join('\r\n'); - } -}; diff --git a/website/scripts/commands/date.js b/website/scripts/commands/date.js deleted file mode 100644 index 1b4f120..0000000 --- a/website/scripts/commands/date.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Date command - Display current date and time - */ -export default { - description: 'Display current date and time', - execute: () => { - const now = new Date(); - return [ - '', - ' ' + now.toLocaleDateString('en-US', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric' - }), - ' ' + now.toLocaleTimeString('en-US'), - '' - ].join('\r\n'); - } -}; diff --git a/website/scripts/commands/donate.js b/website/scripts/commands/donate.js deleted file mode 100644 index 9362798..0000000 --- a/website/scripts/commands/donate.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Donate command - Support the developer - */ -export default { - description: 'Support via donation', - execute: () => { - return [ - '', - ' If you enjoy what I do, consider buying me a iced coffee!', - '', - ' 💳 Donate: https://donate.stripe.com/9AQdRv6ttfv40Ra289', - '', - ' Your support allows me to continue developing and maintaining my projects, and is greatly appreciated!', - '' - ].join('\r\n'); - } -}; diff --git a/website/scripts/commands/echo.js b/website/scripts/commands/echo.js deleted file mode 100644 index 9bfdba9..0000000 --- a/website/scripts/commands/echo.js +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Echo command - Echo back message - */ -export default { - description: 'Echo back message', - execute: (term, writeClickable, VERSION, args) => { - return args.join(' ') || ''; - } -}; diff --git a/website/scripts/commands/github.js b/website/scripts/commands/github.js deleted file mode 100644 index f148680..0000000 --- a/website/scripts/commands/github.js +++ /dev/null @@ -1,9 +0,0 @@ -/** - * GitHub command - Open GitHub repository - */ -export default { - description: 'Open GitHub repository', - execute: () => { - return '\r\n Opening GitHub...\r\n (This would open your repository URL)\r\n'; - } -}; diff --git a/website/scripts/commands/help.js b/website/scripts/commands/help.js deleted file mode 100644 index fb59951..0000000 --- a/website/scripts/commands/help.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Help command - Display available commands - */ -export default { - description: 'Display available commands', - execute: (term, writeClickable) => { - term.writeln(''); - term.writeln('╔════════════════════════════════════════════════════════════╗'); - term.writeln('║ AVAILABLE COMMANDS ║'); - term.writeln('╚════════════════════════════════════════════════════════════╝'); - term.writeln(''); - writeClickable(' [command=help] - Display this help message'); - writeClickable(' [command=about] - Information about this terminal'); - writeClickable(' [command=clear] - Clear the terminal screen'); - term.writeln(' echo - Echo back your message (usage: echo [message])'); - writeClickable(' [command=date] - Display current date and time'); - writeClickable(' [command=whoami] - Display current user information'); - writeClickable(' [command=history] - Show command history'); - writeClickable(' [command=color] - Change terminal color scheme'); - writeClickable(' [command=banner] - Display welcome banner'); - writeClickable(' [command=bluesky] - Fetch recent posts from Bluesky'); - writeClickable(' [command=chat] - Enter interactive chat (type /quit to exit)'); - writeClickable(' [command=numbermatch] - Play number matching puzzle game'); - writeClickable(' [command=github] - Visit GitHub repository'); - writeClickable(' [command=contact] - Display contact information'); - writeClickable(' [command=privacy] - Display privacy policy'); - term.writeln(''); - term.writeln('Navigate: Use ↑/↓ arrows for command history'); - term.writeln('Mouse: Click commands to run them'); - term.writeln(''); - return null; - } -}; diff --git a/website/scripts/commands/history.js b/website/scripts/commands/history.js deleted file mode 100644 index b8a4e7f..0000000 --- a/website/scripts/commands/history.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * History command - Show command history - */ -export default { - description: 'Show command history', - execute: (term, writeClickable, VERSION, args, commandHistory) => { - if (commandHistory.length === 0) { - return '\r\n No command history yet.\r\n'; - } - let output = ['\r\n Command History:', ' ───────────────']; - commandHistory.forEach((cmd, idx) => { - output.push(` ${(idx + 1).toString().padStart(3, ' ')} ${cmd}`); - }); - output.push(''); - return output.join('\r\n'); - } -}; diff --git a/website/scripts/commands/numbermatch.js b/website/scripts/commands/numbermatch.js deleted file mode 100644 index 8217a40..0000000 --- a/website/scripts/commands/numbermatch.js +++ /dev/null @@ -1,951 +0,0 @@ -/** - * Number Match game command - A terminal-based number matching puzzle game - * Match pairs of numbers that are equal or sum to 10 - */ - -/** - * SAM (Software Automatic Mouth) speech synthesizer instance - * Uses default SAM voice preset for classic C64 feel - * @type {object|null} - */ -let sam = null; - -/** - * Initialize SAM speech synthesizer with SAM voice - * @returns {object|null} SAM instance or null if unavailable - */ -function initSam() { - if (sam) return sam; - if (typeof SamJs !== 'undefined') { - // SAM preset: speed=72, pitch=64, mouth=128, throat=128 - sam = new SamJs({ speed: 72, pitch: 64, mouth: 128, throat: 128 }); - } - return sam; -} - -/** - * Speak text using SAM - * @param {string} text - Text to speak - * @returns {void} - */ -function samSpeak(text) { - const samInstance = initSam(); - if (samInstance) { - try { - samInstance.speak(text); - } catch (_err) { - // Silently fail if speech doesn't work - } - } -} - -/** - * Play a click/select sound - * @returns {void} - */ -function playClickSound() { - if (window.terminalSounds) { - const sounds = window.terminalSounds; - sounds.play(sounds.pickRandom(sounds.files.typing), 0.25); - } -} - -/** - * Play a match success sound - * @returns {void} - */ -function playMatchSound() { - if (window.terminalSounds) { - const sounds = window.terminalSounds; - sounds.play(sounds.pickRandom(sounds.files.enter), 0.3); - } -} - -/** - * Play an error/invalid sound - * @returns {void} - */ -function playErrorSound() { - if (window.terminalSounds) { - const sounds = window.terminalSounds; - sounds.play(sounds.files.scroll, 0.2); - } -} - -/** - * Shuffle an array in place using the Fisher-Yates algorithm. - * @template T - * @param {T[]} array - Array to shuffle - * @returns {T[]} The shuffled array - */ -function shuffle(array) { - for (let i = array.length - 1; i > 0; i -= 1) { - const j = Math.floor(Math.random() * (i + 1)); - const temp = array[i]; - array[i] = array[j]; - array[j] = temp; - } - return array; -} - -/** - * Core game state manager for Number Match logic. - */ -class NumberMatchGame { - /** - * @param {{ width?: number, rows?: number }} [options] - Configuration object - */ - constructor(options = {}) { - const { width = 9, rows = 4 } = options; - this.width = width; - this.initialRows = rows; - this.reset(); - } - - /** - * Regenerate the board with a fresh puzzle. - * @returns {void} - */ - reset() { - const initialState = NumberMatchGame.generateInitialState(this.width, this.initialRows); - this.tiles = initialState.boardTiles; - } - - /** - * Get a shallow copy of the current tiles. - * @returns {(number|null)[]} Current tile values - */ - getTiles() { - return [...this.tiles]; - } - - /** - * Count the remaining non-null tiles. - * @returns {number} Number of tiles left on the board - */ - getRemainingCount() { - return this.tiles.filter((tile) => tile !== null).length; - } - - /** - * Determine whether the board has been cleared. - * @returns {boolean} True when all tiles have been removed - */ - isComplete() { - return this.tiles.every((tile) => tile === null); - } - - /** - * Attempt to match and remove two indices. - * @param {number} firstIndex - Index of the first tile - * @param {number} secondIndex - Index of the second tile - * @returns {boolean} True if the match succeeded - */ - selectPair(firstIndex, secondIndex) { - if (!this.canPair(firstIndex, secondIndex)) { - return false; - } - - this.tiles[firstIndex] = null; - this.tiles[secondIndex] = null; - this.compactEmptyRows(); - return true; - } - - /** - * Check whether the two indices can form a valid pair. - * @param {number} firstIndex - Index of the first tile - * @param {number} secondIndex - Index of the second tile - * @returns {boolean} True if the tiles satisfy match rules and path constraints - */ - canPair(firstIndex, secondIndex) { - if (firstIndex === secondIndex) { - return false; - } - - if (!NumberMatchGame.isValidIndex(firstIndex, this.tiles) || - !NumberMatchGame.isValidIndex(secondIndex, this.tiles)) { - return false; - } - - const valueA = this.tiles[firstIndex]; - const valueB = this.tiles[secondIndex]; - - if (valueA === null || valueB === null) { - return false; - } - - if (!(valueA === valueB || valueA + valueB === 10)) { - return false; - } - - return this.hasClearPath(firstIndex, secondIndex); - } - - /** - * Append remaining tiles in reading order. - * @returns {boolean} True when tiles were appended - */ - addNumbers() { - this.compactEmptyRows(); - const remaining = this.tiles.filter((tile) => tile !== null); - if (remaining.length === 0) { - return false; - } - - this.trimTrailingEmptySlots(); - this.tiles.push(...remaining); - return true; - } - - /** - * Remove trailing null placeholders from the board. - * @returns {void} - */ - trimTrailingEmptySlots() { - while (this.tiles.length > 0 && this.tiles[this.tiles.length - 1] === null) { - this.tiles.pop(); - } - } - - /** - * Verify that a straight path between two indices is unobstructed. - * @param {number} firstIndex - One tile index - * @param {number} secondIndex - The other tile index - * @returns {boolean} True if any allowed path between the indices is clear - */ - hasClearPath(firstIndex, secondIndex) { - const start = Math.min(firstIndex, secondIndex); - const end = Math.max(firstIndex, secondIndex); - - const rowStart = Math.floor(start / this.width); - const rowEnd = Math.floor(end / this.width); - const colStart = start % this.width; - const colEnd = end % this.width; - - // Horizontal (same row, adjacent or with empty cells between) - if (rowStart === rowEnd && this.isSegmentClear(start, end, 1)) { - return true; - } - - // Vertical (same column) - const diff = end - start; - if (colStart === colEnd && diff % this.width === 0 && this.isSegmentClear(start, end, this.width)) { - return true; - } - - // Diagonal down-right (row increases, col increases) - const rowDelta = rowEnd - rowStart; - const colDelta = colEnd - colStart; - - if (rowDelta === colDelta && rowDelta > 0) { - // Down-right diagonal: step = width + 1 - if (this.isSegmentClear(start, end, this.width + 1)) { - return true; - } - } - - // Diagonal down-left (row increases, col decreases) - if (rowDelta === -colDelta && rowDelta > 0) { - // Down-left diagonal: step = width - 1 - if (this.isSegmentClear(start, end, this.width - 1)) { - return true; - } - } - - // Wrap-around: scan reading order across row boundaries (right to end of row, - // down to next row, left-to-right until reaching the second tile) - if (rowStart !== rowEnd && this.isSegmentClear(start, end, 1)) { - return true; - } - - return false; - } - - /** - * Remove any fully empty rows from the board to keep the grid compact. - * @returns {void} - */ - compactEmptyRows() { - let index = 0; - while (index < this.tiles.length) { - const remaining = this.tiles.length - index; - const span = Math.min(this.width, remaining); - const slice = this.tiles.slice(index, index + span); - const isEmptyRow = slice.every((value) => value === null); - if (isEmptyRow) { - this.tiles.splice(index, span); - continue; - } - index += this.width; - } - } - - /** - * Verify that the indices along a stepped path are empty. - * @param {number} start - Starting index (inclusive) - * @param {number} end - Ending index (inclusive) - * @param {number} step - Increment applied each iteration - * @returns {boolean} True if no non-null tiles exist between start and end - */ - isSegmentClear(start, end, step) { - for (let current = start + step; current < end; current += step) { - if (this.tiles[current] !== null) { - return false; - } - } - return true; - } - - /** - * Find a valid pair hint. - * @returns {[number, number]|null} A pair of indices or null if none available - */ - findHint() { - const tiles = this.tiles; - for (let i = 0; i < tiles.length; i++) { - if (tiles[i] === null) continue; - for (let j = i + 1; j < tiles.length; j++) { - if (tiles[j] === null) continue; - if (this.canPair(i, j)) { - return [i, j]; - } - } - } - return null; - } - - /** - * Determine whether the provided index is valid for the current tile array. - * @param {number} index - Index to evaluate - * @param {(number|null)[]} tiles - Tile array - * @returns {boolean} True when the index is within bounds - */ - static isValidIndex(index, tiles) { - return index >= 0 && index < tiles.length; - } - - /** - * Create a randomized board configuration. - * @param {number} width - Number of columns - * @param {number} rows - Number of initial rows - * @returns {{ boardTiles: number[] }} Generated tile set - */ - static generateInitialState(width, rows) { - const boardCapacity = width * rows; - const maxBoardMatches = Math.min(6, Math.floor(boardCapacity / 2)); - const forcedPairs = NumberMatchGame.getForcedPairs(maxBoardMatches); - const forcedAssignments = new Map(); - - const horizontalStarts = []; - for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) { - for (let colIndex = 0; colIndex < width - 1; colIndex += 1) { - horizontalStarts.push(rowIndex * width + colIndex); - } - } - - const shuffledStarts = shuffle(horizontalStarts); - const reserved = new Set(); - let startCursor = 0; - - forcedPairs.forEach((pair) => { - let startIndex = null; - while (startCursor < shuffledStarts.length) { - const candidate = shuffledStarts[startCursor]; - startCursor += 1; - if (reserved.has(candidate) || reserved.has(candidate + 1)) { - continue; - } - startIndex = candidate; - break; - } - - if (startIndex === null) { - return; - } - - forcedAssignments.set(startIndex, pair[0]); - forcedAssignments.set(startIndex + 1, pair[1]); - reserved.add(startIndex); - reserved.add(startIndex + 1); - }); - - const boardTiles = new Array(boardCapacity).fill(null); - - for (let index = 0; index < boardCapacity; index += 1) { - const forcedValue = forcedAssignments.get(index); - if (typeof forcedValue === 'number') { - boardTiles[index] = forcedValue; - continue; - } - - const rawNextForced = forcedAssignments.get(index + 1); - const nextForcedValue = typeof rawNextForced === 'number' ? rawNextForced : null; - boardTiles[index] = NumberMatchGame.pickSafeFillerValue(boardTiles, width, index, nextForcedValue); - } - - return { boardTiles }; - } - - /** - * Produce a random pair of values that form a legal match. - * @returns {[number, number]} Two values that either match or sum to ten - */ - static generatePair() { - const complementPairs = [[1, 9], [2, 8], [3, 7], [4, 6], [5, 5]]; - const identicalValues = [1, 2, 3, 4, 5, 6, 7, 8, 9]; - - if (Math.random() < 0.5) { - const value = identicalValues[Math.floor(Math.random() * identicalValues.length)]; - return [value, value]; - } - - const pair = complementPairs[Math.floor(Math.random() * complementPairs.length)]; - return [...pair]; - } - - /** - * Create the predetermined matches that appear on the initial board. - * @param {number} target - Total matches to prepare - * @returns {Array<[number, number]>} Ordered list of forced pairs - */ - static getForcedPairs(target) { - const essentialPairs = [[5, 5]]; - const complementPool = shuffle([ - [1, 9], [2, 8], [3, 7], [4, 6], - [9, 1], [8, 2], [7, 3], [6, 4] - ]); - const pairs = []; - - for (let i = 0; i < essentialPairs.length && pairs.length < target; i += 1) { - pairs.push(essentialPairs[i]); - } - - let complementIndex = 0; - while (pairs.length < target && complementIndex < complementPool.length) { - pairs.push(complementPool[complementIndex]); - complementIndex += 1; - } - - while (pairs.length < target) { - pairs.push(NumberMatchGame.generatePair()); - } - - return shuffle(pairs); - } - - /** - * Pick a filler value that will not immediately create an accessible match. - * @param {Array} tiles - Current partially filled board - * @param {number} width - Board width - * @param {number} index - Index to populate - * @param {number|null} nextForcedValue - Upcoming forced value, if one exists - * @returns {number} Safe filler digit - */ - static pickSafeFillerValue(tiles, width, index, nextForcedValue) { - const attempt = (respectNext) => { - const forbidden = NumberMatchGame.collectForbiddenValues(tiles, width, index, respectNext ? nextForcedValue : null); - const candidates = []; - for (let value = 1; value <= 9; value += 1) { - if (!forbidden.has(value)) { - candidates.push(value); - } - } - return candidates; - }; - - let candidates = attempt(true); - if (candidates.length === 0) { - candidates = attempt(false); - } - - if (candidates.length === 0) { - return Math.floor(Math.random() * 9) + 1; - } - - return candidates[Math.floor(Math.random() * candidates.length)]; - } - - /** - * Collect forbidden values based on already placed neighbors. - * @param {Array} tiles - Current board state - * @param {number} width - Board width - * @param {number} index - Target index - * @param {number|null} nextForcedValue - Forced value to appear next in sequence, if any - * @returns {Set} Values that should not be used at the index - */ - static collectForbiddenValues(tiles, width, index, nextForcedValue) { - const forbidden = new Set(); - const register = NumberMatchGame.registerForbiddenValue; - - if (index > 0) { - register(forbidden, tiles[index - 1]); - } - - const row = Math.floor(index / width); - const col = index % width; - - if (row > 0) { - register(forbidden, tiles[index - width]); - if (col > 0) { - register(forbidden, tiles[index - width - 1]); - } - if (col < width - 1) { - register(forbidden, tiles[index - width + 1]); - } - } - - if (typeof nextForcedValue === 'number') { - register(forbidden, nextForcedValue); - } - - return forbidden; - } - - /** - * Register a value and its complement as forbidden for immediate placement. - * @param {Set} forbidden - Collection of disallowed values - * @param {number|null|undefined} value - Value to mark as forbidden - * @returns {void} - */ - static registerForbiddenValue(forbidden, value) { - if (typeof value !== 'number') { - return; - } - - forbidden.add(value); - const complement = NumberMatchGame.getComplement(value); - forbidden.add(complement); - } - - /** - * Compute the complement digit that forms a sum-to-ten relationship. - * @param {number} value - Value between 1 and 9 - * @returns {number} Complement digit - */ - static getComplement(value) { - if (value === 5) { - return 5; - } - const complement = 10 - value; - return Math.min(9, Math.max(1, complement)); - } -} - -/** - * Game mode state for terminal integration - */ -export const gameMode = { - active: false, - game: null, - selectedIndex: null, - matchesCleared: 0, - term: null, - boardLines: 0, - message: '' -}; - -/** - * Calculate how many lines the board display uses - * @param {NumberMatchGame} game - Game instance - * @returns {number} Number of lines to move up - */ -function getBoardLineCount(game) { - const rows = Math.ceil(game.getTiles().length / game.width); - // header + top border + rows + blank lines between rows + bottom border + empty + status + message + controls - // (prompt uses write not writeln, so doesn't add a line) - return 1 + 1 + rows + Math.max(0, rows - 1) + 1 + 1 + 1 + 1 + 1; -} - -/** - * Render the game board as ASCII art (initial draw) - * @param {Terminal} term - xterm.js terminal instance - * @param {NumberMatchGame} game - Game instance - * @param {number|null} selectedIndex - Currently selected tile index - * @param {string} message - Status message to display - * @returns {void} - */ -function renderBoard(term, game, selectedIndex, message = '') { - const tiles = game.getTiles(); - const width = game.width; - const rows = Math.ceil(tiles.length / width); - - // Column headers - let header = ' '; - for (let c = 0; c < width; c++) { - header += ` ${c + 1} `; - } - term.writeln(`\x1b[90m${header}\x1b[0m`); - - // Top border - term.writeln(` ┌${'───'.repeat(width)}┐`); - - for (let row = 0; row < rows; row++) { - let line = ` ${String.fromCharCode(65 + row)} │`; - - for (let col = 0; col < width; col++) { - const idx = row * width + col; - if (idx >= tiles.length) { - line += ' '; - continue; - } - - const value = tiles[idx]; - if (value === null) { - line += ' · '; - } else if (idx === selectedIndex) { - // Highlight selected tile - line += `\x1b[7m ${value} \x1b[0m`; - } else { - line += ` ${value} `; - } - } - - line += '│'; - term.writeln(line); - if (row < rows - 1) { - term.writeln(` │${' '.repeat(width)}│`); - } - } - - // Bottom border - term.writeln(` └${'───'.repeat(width)}┘`); - - // Status line - term.writeln(''); - const remaining = game.getRemainingCount(); - term.writeln(` \x1b[33mMatches:\x1b[0m ${gameMode.matchesCleared} \x1b[33mRemaining:\x1b[0m ${remaining} `); - - // Message line (padded to clear previous) - const msgText = message || ''; - term.writeln(` ${msgText}`.padEnd(50)); - - // Clickable controls line - term.writeln(' \x1b[90m[ \x1b[36madd\x1b[90m ] [ \x1b[36mhint\x1b[90m ] [ \x1b[36mnew\x1b[90m ] [ \x1b[36mquit\x1b[90m ]\x1b[0m'); - - // Prompt - term.write('\x1b[33mgame>\x1b[0m '); - - gameMode.boardLines = getBoardLineCount(game); - gameMode.message = message; -} - -/** - * Update the board in place without scrolling - * @param {Terminal} term - xterm.js terminal instance - * @param {NumberMatchGame} game - Game instance - * @param {number|null} selectedIndex - Currently selected tile index - * @param {string} message - Status message to display - * @returns {void} - */ -function updateBoardInPlace(term, game, selectedIndex, message = '') { - const linesToMoveUp = gameMode.boardLines; - - // Move cursor up to start of board, go to column 0, clear to end of screen - term.write(`\x1b[${linesToMoveUp}A\r\x1b[J`); - - // Redraw from current position - renderBoard(term, game, selectedIndex, message); -} - -/** - * Display help text - * @param {Terminal} term - xterm.js terminal instance - * @returns {void} - */ -function renderHelp(term) { - term.writeln(''); - term.writeln(' \x1b[36mCommands:\x1b[0m'); - term.writeln(' A1 B2 - Select two tiles (e.g., A1 A2 or B3 C3)'); - term.writeln(' add - Duplicate remaining numbers to end'); - term.writeln(' hint - Show a valid pair'); - term.writeln(' new - Start a new game'); - term.writeln(' quit - Exit the game'); - term.writeln(''); - term.writeln(' \x1b[36mRules:\x1b[0m Match numbers that are equal or sum to 10'); - term.writeln(' Pairs must be adjacent horizontally or vertically'); - term.writeln(' (with only empty spaces between them)'); -} - -/** - * Parse coordinate input like "A1" to index - * @param {string} coord - Coordinate string (e.g., "A1", "B3") - * @param {number} width - Board width - * @returns {number|null} Tile index or null if invalid - */ -function parseCoordinate(coord, width) { - const match = coord.toUpperCase().match(/^([A-Z])(\d+)$/); - if (!match) return null; - - const row = match[1].charCodeAt(0) - 65; - const col = parseInt(match[2], 10) - 1; - - if (row < 0 || col < 0 || col >= width) return null; - - return row * width + col; -} - -/** - * Process game input - * @param {Terminal} term - xterm.js terminal instance - * @param {string} input - User input - * @returns {boolean} True if game should continue - */ -export function processGameInput(term, input) { - if (!gameMode.active || !gameMode.game) { - return false; - } - - const cmd = input.trim().toLowerCase(); - const game = gameMode.game; - - if (cmd === 'quit' || cmd === 'exit' || cmd === '/quit') { - gameMode.active = false; - gameMode.game = null; - gameMode.selectedIndex = null; - gameMode.matchesCleared = 0; - gameMode.term = null; - gameMode.boardLines = 0; - term.writeln(' Game exited.\r\n'); - return false; - } - - if (cmd === 'new' || cmd === 'reset') { - game.reset(); - gameMode.selectedIndex = null; - gameMode.matchesCleared = 0; - term.writeln(''); - renderBoard(term, game, null, 'New game started!'); - return true; - } - - if (cmd === 'add') { - const added = game.addNumbers(); - term.writeln(''); - if (added) { - renderBoard(term, game, gameMode.selectedIndex, 'Numbers duplicated to end'); - } else { - renderBoard(term, game, gameMode.selectedIndex, '\x1b[31mNo numbers to add\x1b[0m'); - } - return true; - } - - if (cmd === 'hint') { - const hint = game.findHint(); - if (hint) { - const [i, j] = hint; - const width = game.width; - const coord1 = String.fromCharCode(65 + Math.floor(i / width)) + ((i % width) + 1); - const coord2 = String.fromCharCode(65 + Math.floor(j / width)) + ((j % width) + 1); - term.writeln(` \x1b[33mHint:\x1b[0m Try ${coord1} ${coord2}`); - } else { - term.writeln(' \x1b[31mNo matches. Try "add"\x1b[0m'); - } - term.write('\x1b[33mgame>\x1b[0m '); - return true; - } - - if (cmd === 'help' || cmd === '?') { - renderHelp(term); - term.write('\x1b[33mgame>\x1b[0m '); - return true; - } - - // Try to parse as coordinates - const parts = input.trim().toUpperCase().split(/\s+/); - - if (parts.length === 2) { - const idx1 = parseCoordinate(parts[0], game.width); - const idx2 = parseCoordinate(parts[1], game.width); - - if (idx1 === null || idx2 === null) { - term.writeln(' \x1b[31mInvalid coordinates. Use format: A1 B2\x1b[0m'); - term.write('\x1b[33mgame>\x1b[0m '); - return true; - } - - const tiles = game.getTiles(); - if (idx1 >= tiles.length || idx2 >= tiles.length) { - term.writeln(' \x1b[31mCoordinates out of range.\x1b[0m'); - term.write('\x1b[33mgame>\x1b[0m '); - return true; - } - - if (tiles[idx1] === null || tiles[idx2] === null) { - term.writeln(' \x1b[31mOne or both tiles are empty.\x1b[0m'); - term.write('\x1b[33mgame>\x1b[0m '); - return true; - } - - const v1 = tiles[idx1]; - const v2 = tiles[idx2]; - const matched = game.selectPair(idx1, idx2); - - term.writeln(''); - if (matched) { - gameMode.matchesCleared++; - samSpeak('match'); - - if (game.isComplete()) { - samSpeak('Condrat ulationz'); - term.writeln(' \x1b[32m╔════════════════════════════════════╗\x1b[0m'); - term.writeln(' \x1b[32m║ CONGRATULATIONS! YOU WON! ║\x1b[0m'); - term.writeln(' \x1b[32m╚════════════════════════════════════╝\x1b[0m'); - term.writeln(''); - term.writeln(` Total matches: ${gameMode.matchesCleared}`); - term.writeln(' Type "new" for another game or "quit" to exit.'); - term.write('\x1b[33mgame>\x1b[0m '); - gameMode.boardLines = 0; - return true; - } - renderBoard(term, game, null, `\x1b[32mMatch! ${v1}+${v2}\x1b[0m`); - } else { - samSpeak('no'); - if (v1 !== v2 && v1 + v2 !== 10) { - renderBoard(term, game, null, `\x1b[31m${v1} and ${v2} don't match\x1b[0m`); - } else { - renderBoard(term, game, null, `\x1b[31mNo clear path\x1b[0m`); - } - } - return true; - } - - if (parts.length === 1 && parts[0]) { - const idx = parseCoordinate(parts[0], game.width); - if (idx !== null) { - term.writeln(' \x1b[33mEnter two coordinates (e.g., A1 A2)\x1b[0m'); - term.write('\x1b[33mgame>\x1b[0m '); - return true; - } - } - - if (cmd) { - term.writeln(' \x1b[31mUnknown command. Type "help"\x1b[0m'); - } - term.write('\x1b[33mgame>\x1b[0m '); - return true; -} - -/** - * Start a new number match game session - * @param {Terminal} term - xterm.js terminal instance - * @returns {void} - */ -export function startGame(term) { - gameMode.active = true; - gameMode.game = new NumberMatchGame({ width: 9, rows: 4 }); - gameMode.selectedIndex = null; - gameMode.matchesCleared = 0; - gameMode.term = term; - gameMode.boardLines = 0; - gameMode.message = ''; - - term.writeln(''); - term.writeln(' ╔════════════════════════════════════╗'); - term.writeln(' ║ NUMBER MATCH ║'); - term.writeln(' ╚════════════════════════════════════╝'); - term.writeln(''); - term.writeln(' Match pairs: equal numbers or sum to 10'); - term.writeln(' \x1b[90mClick tiles or type coordinates (e.g., A1 A2)\x1b[0m'); - term.writeln(' \x1b[90mCommands: add, hint, new, quit\x1b[0m'); - term.writeln(''); - - samSpeak('Number Match'); - renderBoard(term, gameMode.game, null, ''); -} - -/** - * Handle a tile click from the terminal link provider - * @param {string} coord - Coordinate string (e.g., "A1", "B3") - * @returns {void} - */ -export function handleTileClick(coord) { - if (!gameMode.active || !gameMode.game || !gameMode.term) { - return; - } - - const term = gameMode.term; - const game = gameMode.game; - const clickedIndex = parseCoordinate(coord, game.width); - - if (clickedIndex === null) { - return; - } - - const tiles = game.getTiles(); - if (clickedIndex >= tiles.length || tiles[clickedIndex] === null) { - return; - } - - // If no tile selected, select this one - if (gameMode.selectedIndex === null) { - gameMode.selectedIndex = clickedIndex; - playClickSound(); - samSpeak(String(tiles[clickedIndex])); - updateBoardInPlace(term, game, clickedIndex, `\x1b[33mSelected ${coord}. Click another to match.\x1b[0m`); - return; - } - - // If same tile clicked, deselect - if (gameMode.selectedIndex === clickedIndex) { - gameMode.selectedIndex = null; - playClickSound(); - updateBoardInPlace(term, game, null, ''); - return; - } - - // Try to match the two tiles - const firstIndex = gameMode.selectedIndex; - const prevTiles = game.getTiles(); - const v1 = prevTiles[firstIndex]; - const v2 = prevTiles[clickedIndex]; - - const matched = game.selectPair(firstIndex, clickedIndex); - gameMode.selectedIndex = null; - - if (matched) { - gameMode.matchesCleared++; - playMatchSound(); - samSpeak('match'); - - if (game.isComplete()) { - // Game won - need to redraw fresh for victory screen - playMatchSound(); - samSpeak('Condrat ulationz'); - term.write(`\x1b[${gameMode.boardLines}A`); - for (let i = 0; i < gameMode.boardLines; i++) { - term.writeln('\x1b[2K'); - } - term.writeln(' \x1b[32m╔════════════════════════════════════╗\x1b[0m'); - term.writeln(' \x1b[32m║ CONGRATULATIONS! YOU WON! ║\x1b[0m'); - term.writeln(' \x1b[32m╚════════════════════════════════════╝\x1b[0m'); - term.writeln(''); - term.writeln(` Total matches: ${gameMode.matchesCleared}`); - term.writeln(' Type "new" for another game or "quit" to exit.'); - term.write('\x1b[33mgame>\x1b[0m '); - gameMode.boardLines = 0; - return; - } - - updateBoardInPlace(term, game, null, `\x1b[32mMatch! ${v1}+${v2}\x1b[0m`); - } else { - playErrorSound(); - samSpeak('no'); - if (v1 !== v2 && v1 + v2 !== 10) { - updateBoardInPlace(term, game, null, `\x1b[31m${v1} and ${v2} don't match or sum to 10\x1b[0m`); - } else { - updateBoardInPlace(term, game, null, `\x1b[31mNo clear path between tiles\x1b[0m`); - } - } -} - -/** - * Number Match command export - */ -export default { - description: 'Play the Number Match puzzle game', - execute: (term, writeClickable, VERSION, args) => { - startGame(term); - return null; - } -}; diff --git a/website/scripts/commands/privacy.js b/website/scripts/commands/privacy.js deleted file mode 100644 index 1beacad..0000000 --- a/website/scripts/commands/privacy.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Privacy command - Privacy policy - */ -export default { - description: 'Privacy policy', - execute: () => { - return [ - '', - '╔════════════════════════════════════════════════════════════╗', - '║ PRIVACY POLICY ║', - '╚════════════════════════════════════════════════════════════╝', - '', - ' Data Collection:', - ' ────────────────', - ' • This terminal uses localStorage to save your chat session', - ' • Chat messages are stored on our Matrix homeserver', - ' • No cookies or tracking scripts are used', - ' • No analytics or third-party tracking', - '', - ' Matrix Chat:', - ' ────────────', - ' • Chat credentials stored locally in your browser', - ' • Messages sent through Matrix protocol (b.ruv.wtf)', - ' • Use "chat disconnect" to clear stored credentials', - '', - ' Your Rights:', - ' ────────────', - ' • Clear localStorage anytime via browser settings', - ' • Request data deletion: contact@lit.ruv.wtf', - ' • All code is open source and auditable', - '', - ' Updates: Privacy policy last updated March 2026', - '' - ].join('\r\n'); - } -}; diff --git a/website/scripts/commands/samsay.js b/website/scripts/commands/samsay.js deleted file mode 100644 index c587a6c..0000000 --- a/website/scripts/commands/samsay.js +++ /dev/null @@ -1,56 +0,0 @@ -/** - * SAM Say command - Use SAM (Software Automatic Mouth) to speak text - */ - -/** - * SAM (Software Automatic Mouth) speech synthesizer instance - * @type {object|null} - */ -let sam = null; - -/** - * Initialize SAM speech synthesizer with SAM voice - * @returns {object|null} SAM instance or null if unavailable - */ -function initSam() { - if (sam) return sam; - if (typeof SamJs !== 'undefined') { - // SAM preset: speed=72, pitch=64, mouth=128, throat=128 - sam = new SamJs({ speed: 72, pitch: 64, mouth: 128, throat: 128 }); - } - return sam; -} - -/** - * Speak text using SAM - * @param {string} text - Text to speak - * @returns {void} - */ -function samSpeak(text) { - const samInstance = initSam(); - if (samInstance) { - try { - samInstance.speak(text); - } catch (_err) { - // Silently fail if speech doesn't work - } - } -} - -export default { - description: 'Use SAM speech synthesizer to speak text', - execute: (term, writeClickable, VERSION, args) => { - const message = args.join(' '); - - if (!message) { - return 'Usage: samsay '; - } - - if (typeof SamJs === 'undefined') { - return 'SAM speech synthesizer is not available.'; - } - - samSpeak(message); - return `SAM says: ${message}`; - } -}; diff --git a/website/scripts/commands/whoami.js b/website/scripts/commands/whoami.js deleted file mode 100644 index db9ff4a..0000000 --- a/website/scripts/commands/whoami.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Whoami command - Display user information - */ -export default { - description: 'Display user information', - execute: () => { - return [ - '', - ' User: visitor@lit.ruv.wtf', - ' Session: ' + Date.now(), - ' Terminal: xterm.js', - '' - ].join('\r\n'); - } -}; diff --git a/website/scripts/getTypeColor.js b/website/scripts/getTypeColor.js new file mode 100644 index 0000000..dfa09c7 --- /dev/null +++ b/website/scripts/getTypeColor.js @@ -0,0 +1,21 @@ +/** @type {Record} */ +const TYPE_COLORS = { + exec: "#ffffff", + number: "#3ee581", + boolean: "#ff4f4f", + string: "#ff66ff", + table: "#5ec4ff", + color: "#5b8ef5", + object: "#64b5f6", + any: "#8c919d", +}; + +/** + * Returns the CSS color for a given pin kind. + * + * @param {string} kind + * @returns {string} + */ +export function getTypeColor(kind) { + return TYPE_COLORS[kind] ?? TYPE_COLORS.any; +} diff --git a/website/scripts/main.js b/website/scripts/main.js new file mode 100644 index 0000000..869b6b3 --- /dev/null +++ b/website/scripts/main.js @@ -0,0 +1,672 @@ +import { WorkspaceNavigator } from "./WorkspaceNavigator.js"; +import { NodeRenderer } from "./NodeRenderer.js"; +import { ConnectionRenderer } from "./ConnectionRenderer.js"; +import { GraphNode } from "./GraphNode.js"; +import { GraphConnection } from "./GraphConnection.js"; +import { GraphComment } from "./GraphComment.js"; +import { DebugPanel } from "./DebugPanel.js"; +import { PropertiesPanel } from "./PropertiesPanel.js"; +import { ItemDragger } from "./ItemDragger.js"; +import { PinConnectionManager } from "./PinConnectionManager.js"; +import { GraphExecutor } from "./GraphExecutor.js"; +import { PrintBubble } from "./PrintBubble.js"; +import { SpatialAudio } from "./SpatialAudio.js"; +import { getTypeColor } from "./getTypeColor.js"; + +const canvas = document.getElementById("workspaceCanvas"); +const worldLayer = document.getElementById("worldLayer"); +const nodeLayer = document.getElementById("nodeLayer"); +const connLayerSvg = document.getElementById("connectionLayer"); +const worldBoxLayer = document.getElementById("worldBoxLayer"); +const viewboxLayer = document.getElementById("viewboxLayer"); +const jsonEditorModal = document.getElementById("jsonEditorModal"); +const jsonEditorTextarea = document.getElementById("jsonEditorTextarea"); +const resetBtn = document.getElementById("resetViewBtn"); +const draggableToggle = document.getElementById("draggableToggle"); +const debugPanelEl = document.getElementById("debugPanel"); +const propertiesPanelEl = document.getElementById("propertiesPanel"); + +/** @type {HTMLTemplateElement} */ +const nodeTemplate = document.getElementById("nodeTemplate"); +/** @type {HTMLTemplateElement} */ +const pinTemplate = document.getElementById("pinTemplate"); + +const nav = new WorkspaceNavigator(canvas, worldLayer); +const audio = new SpatialAudio(() => nav.getViewInfo().viewbox); + +const nodeRend = new NodeRenderer(nodeLayer, nodeTemplate, pinTemplate, (nodeId, pinId, direction) => { + const targetId = nodeRend.getConnectedNodeId(nodeId, pinId, direction); + if (!targetId) return; + const rect = nodeRend.getNodeWorldRect(targetId); + if (!rect) return; + const focusOptions = nodeRend.getNodes().find(n => n.id === targetId)?.focusOptions ?? {}; + nav.animateFocusOnRect(rect, focusOptions); + history.pushState({ nodeId: targetId }, "", `#${targetId}`); +}, (nodeId, pinId) => executor.execute(nodeId, pinId), () => connRend.render()); + +/** @type {Map} */ +const printBubbles = new Map(); + +const executor = new GraphExecutor(nodeRend, (nodeId, value) => { + printBubbles.get(nodeId)?.push(value); + + // Play spatial audio at node position + const node = nodeRend.getNodes().find(n => n.id === nodeId); + if (node) { + const rect = nodeRend.getNodeWorldRect(nodeId); + if (rect) { + const centerX = rect.x + rect.width / 2; + const centerY = rect.y + rect.height / 2; + audio.play('pop', centerX, centerY, rect.width, rect.height); + } + } +}, async (connId, kind) => { + await connRend.activatePath(connId, kind === "exec" ? 200 : 150); +}); +const connRend = new ConnectionRenderer(connLayerSvg, canvas, nodeRend, nav); +const pinConns = new PinConnectionManager(canvas, connLayerSvg, nodeLayer, nodeRend, nav, connRend); + +// Properties Panel - Initialize early so it can be referenced +const propertiesPanel = new PropertiesPanel(propertiesPanelEl, () => { + connRend.render(); + renderWorldBoxes(); + renderViewbox(); +}); + +const dragger = new ItemDragger(canvas, nav, nodeRend, + () => { + const activeNode = dragger.activeId ? nodeRend.getNodes().find(n => n.id === dragger.activeId) : null; + debugPanel.update(nav.getViewInfo(), dragger.activeId, dragger.activeId ? nodeRend.getNodeWorldRect(dragger.activeId) : null, activeNode); + propertiesPanel.loadNode(activeNode); + }, + () => { connRend.render(); } +); +// Suppress navigator pan while a node drag is actively in progress (enables edge-pan on mobile + mouse) +nav.setDragInProgressChecker(() => dragger.isDragging); +// Drop the active drag immediately when a pinch-to-zoom starts so the two gestures don't conflict +nav.setOnPinchStart(() => dragger.cancelDrag()); +const debugPanel = new DebugPanel(debugPanelEl, + (enabled) => { dragger.enabled = enabled; pinConns.enabled = enabled; }, + () => { + const liveGraph = { + settings: graphData.settings, + images: graphData.images, + nodes: nodeRend.getNodes().map(n => ({ + id: n.id, + type: n.type, + title: n.title, + position: { x: Math.round(n.position.x), y: Math.round(n.position.y) }, + ...(n.width !== undefined ? { width: n.width } : {}), + ...(n.markdownSrc !== undefined ? { markdownSrc: n.markdownSrc } : {}), + ...(n.imageSrc !== undefined ? { imageSrc: n.imageSrc } : {}), + ...(n.lottieSrc !== undefined ? { lottieSrc: n.lottieSrc } : {}), + ...(n.focusOptions !== undefined ? { focusOptions: n.focusOptions } : {}), + inputs: n.inputs, + outputs: n.outputs, + })), + connections: nodeRend.getConnections().map(c => ({ + id: c.id, + from: c.from, + to: c.to, + kind: c.kind, + })), + }; + navigator.clipboard.writeText(JSON.stringify(liveGraph, null, 2)); + }, (nodeId, updates) => { + const node = nodeRend.getNodes().find(n => n.id === nodeId); + if (!node) return; + + let needsRerender = false; + + if (updates.title !== undefined) node.title = updates.title; + if (updates.position) { + nodeRend.setNodePosition(nodeId, updates.position.x, updates.position.y); + } + if (updates.width !== undefined) { + node.width = updates.width; + const el = nodeRend.getNodeElement(nodeId); + if (el) el.style.width = updates.width != null ? `${updates.width}px` : ''; + } + + if (updates.inputs !== undefined) { + node.inputs = updates.inputs; + needsRerender = true; + } + if (updates.outputs !== undefined) { + node.outputs = updates.outputs; + needsRerender = true; + } + + const nodeEl = nodeRend.getNodeElement(nodeId); + if (nodeEl && updates.title !== undefined) { + const titleEl = nodeEl.querySelector('.node-title'); + if (titleEl) titleEl.textContent = updates.title; + } + + // Re-render node if pins changed + if (needsRerender && nodeEl) { + const parent = nodeEl.parentElement; + nodeEl.remove(); + + // Re-add using internal render method + const fragment = nodeTemplate.content.cloneNode(true); + const article = fragment.querySelector('.blueprint-node'); + const title = article.querySelector('.node-title'); + const inputs = article.querySelector('.node-inputs'); + const outputs = article.querySelector('.node-outputs'); + + article.dataset.nodeId = node.id; + article.dataset.nodeType = node.type; + title.textContent = node.title; + article.style.transform = `translate3d(${node.position.x}px, ${node.position.y}px, 0)`; + if (node.width != null) article.style.width = `${node.width}px`; + + // Re-render pins using pinTemplate + inputs.innerHTML = ''; + outputs.innerHTML = ''; + + node.inputs.forEach(pin => { + const pinFrag = pinTemplate.content.cloneNode(true); + const container = pinFrag.firstElementChild; + const label = container.querySelector('.pin-label'); + const handle = container.querySelector('.pin-handle'); + + container.dataset.pinId = pin.id; + container.dataset.type = pin.kind; + container.dataset.direction = 'input'; + container.classList.add('is-disconnected'); + container.style.setProperty('--pin-kind-color', getTypeColor(pin.kind)); + + const isStandardExec = pin.kind === 'exec' && (pin.id === 'exec_in' || pin.id === 'exec_out'); + if (isStandardExec && !pin.name) { + label.textContent = ''; + label.classList.add('is-hidden'); + } else { + label.textContent = pin.name; + } + + inputs.appendChild(container); + }); + + node.outputs.forEach(pin => { + const pinFrag = pinTemplate.content.cloneNode(true); + const container = pinFrag.firstElementChild; + const label = container.querySelector('.pin-label'); + const handle = container.querySelector('.pin-handle'); + + container.dataset.pinId = pin.id; + container.dataset.type = pin.kind; + container.dataset.direction = 'output'; + container.classList.add('is-disconnected'); + container.style.setProperty('--pin-kind-color', getTypeColor(pin.kind)); + + const isStandardExec = pin.kind === 'exec' && (pin.id === 'exec_in' || pin.id === 'exec_out'); + if (isStandardExec && !pin.name) { + label.textContent = ''; + label.classList.add('is-hidden'); + } else { + label.textContent = pin.name; + } + + outputs.appendChild(container); + }); + + parent?.appendChild(article); + + // Update internal node elements map + const nodeElements = nodeRend.getNodeElements(); + nodeElements.set(nodeId, article); + + // Update debug panel with refreshed node + const activeNode = nodeRend.getNodes().find(n => n.id === dragger.activeId); + debugPanel.update(nav.getViewInfo(), dragger.activeId, dragger.activeId ? nodeRend.getNodeWorldRect(dragger.activeId) : null, activeNode); + } + + connRend.render(); +}, + (enabled) => { + // WorldBox toggle + if (worldBoxLayer) { + worldBoxLayer.style.display = enabled ? 'block' : 'none'; + renderWorldBoxes(); + } + }, + () => { + // Copy entire graph as JSON + const liveGraph = { + settings: graphData.settings, + images: graphData.images, + comments: graphData.comments, + nodes: nodeRend.getNodes().map(n => ({ + id: n.id, + type: n.type, + title: n.title, + position: { x: Math.round(n.position.x), y: Math.round(n.position.y) }, + ...(n.width !== undefined ? { width: n.width } : {}), + ...(n.markdownSrc !== undefined ? { markdownSrc: n.markdownSrc } : {}), + ...(n.imageSrc !== undefined ? { imageSrc: n.imageSrc } : {}), + ...(n.lottieSrc !== undefined ? { lottieSrc: n.lottieSrc } : {}), + ...(n.focusOptions !== undefined ? { focusOptions: n.focusOptions } : {}), + inputs: n.inputs, + outputs: n.outputs, + })), + connections: nodeRend.getConnections().map(c => ({ + id: c.id, + from: c.from, + to: c.to, + kind: c.kind, + })), + }; + navigator.clipboard.writeText(JSON.stringify(liveGraph, null, 2)); + }, + (enabled) => { + // Viewbox toggle + if (viewboxLayer) { + viewboxLayer.style.display = enabled ? 'block' : 'none'; + renderViewbox(); + } + }, + () => { + // Show Properties Panel + propertiesPanel.expand(); + } +); + +// Connection SVG is screen-space — must re-render on every pan/zoom so paths track node positions. +let hasTransformed = false; +let trackTransform = false; +nav.setOnAfterTransform(() => { + connRend.render(); + renderWorldBoxes(); + renderViewbox(); + const activeNode = dragger.activeId ? nodeRend.getNodes().find(n => n.id === dragger.activeId) : null; + debugPanel.update(nav.getViewInfo(), dragger.activeId, dragger.activeId ? nodeRend.getNodeWorldRect(dragger.activeId) : null, activeNode); + + if (trackTransform && !hasTransformed) { + hasTransformed = true; + canvas.classList.add('has-transformed'); + } +}); + +// ─── WorldBox Visualizer ────────────────────────────────────────────────────── + +/** + * Renders worldbox overlays for all nodes with focusOptions + */ +function renderWorldBoxes() { + if (!worldBoxLayer || worldBoxLayer.style.display === 'none') return; + + // Clear existing + worldBoxLayer.innerHTML = ''; + + const viewInfo = nav.getViewInfo(); + const zoom = viewInfo.zoom; + const offset = nav.getEffectiveOffset(); + const canvasRect = canvas.getBoundingClientRect(); + + nodeRend.getNodes().forEach(node => { + if (!node.focusOptions?.minWorldBox && !node.focusOptions?.responsiveWorldBox) return; + + const rect = nodeRend.getNodeWorldRect(node.id); + if (!rect) return; + + const nodeCenterX = rect.x + rect.width / 2; + const nodeCenterY = rect.y + rect.height / 2; + + // Determine which breakpoint would be active at current viewport width + const vw = window.innerWidth; + let activeBreakpointIndex = -1; // -1 = base worldBox is active + if (node.focusOptions.responsiveWorldBox && Array.isArray(node.focusOptions.responsiveWorldBox)) { + const sorted = [...node.focusOptions.responsiveWorldBox] + .map((bp, i) => ({ bp, i })) + .sort((a, b) => (a.bp.minViewportWidth ?? 0) - (b.bp.minViewportWidth ?? 0)); + for (const { bp, i } of sorted) { + if (vw < (bp.minViewportWidth ?? Infinity)) { + activeBreakpointIndex = i; + break; + } + } + } + + // Render base worldbox if it exists + if (node.focusOptions.minWorldBox) { + const worldBox = node.focusOptions.minWorldBox; + const anchorX = node.focusOptions.anchorX ?? 0.5; + const anchorY = node.focusOptions.anchorY ?? 0.5; + const isActive = activeBreakpointIndex === -1; + + // The viewport region is offset from the node center based on anchor. + // anchor=0.5 means node is centered in viewport (no offset). + // The box shifts so the node sits at the anchor position within it. + const boxX = nodeCenterX - worldBox.width * anchorX; + const boxY = nodeCenterY - worldBox.height * anchorY; + + // Convert to screen space + const screenX = (boxX + offset.x) * zoom; + const screenY = (boxY + offset.y) * zoom; + const screenW = worldBox.width * zoom; + const screenH = worldBox.height * zoom; + + const rectEl = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rectEl.setAttribute('class', isActive ? 'worldbox-rect worldbox-rect--active' : 'worldbox-rect'); + rectEl.setAttribute('x', String(screenX)); + rectEl.setAttribute('y', String(screenY)); + rectEl.setAttribute('width', String(screenW)); + rectEl.setAttribute('height', String(screenH)); + worldBoxLayer.appendChild(rectEl); + + const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + textEl.setAttribute('class', isActive ? 'worldbox-label worldbox-label--active' : 'worldbox-label'); + textEl.setAttribute('x', String(screenX + 6)); + textEl.setAttribute('y', String(screenY + 14)); + textEl.textContent = `${node.id} (${worldBox.width}×${worldBox.height})`; + worldBoxLayer.appendChild(textEl); + + // Draw crosshair at the node center (the focus point) + const anchorScreenX = (nodeCenterX + offset.x) * zoom; + const anchorScreenY = (nodeCenterY + offset.y) * zoom; + const crossSize = 8; + + const hLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + hLine.setAttribute('class', isActive ? 'worldbox-anchor worldbox-anchor--active' : 'worldbox-anchor'); + hLine.setAttribute('x1', String(anchorScreenX - crossSize)); + hLine.setAttribute('y1', String(anchorScreenY)); + hLine.setAttribute('x2', String(anchorScreenX + crossSize)); + hLine.setAttribute('y2', String(anchorScreenY)); + worldBoxLayer.appendChild(hLine); + + const vLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + vLine.setAttribute('class', isActive ? 'worldbox-anchor worldbox-anchor--active' : 'worldbox-anchor'); + vLine.setAttribute('x1', String(anchorScreenX)); + vLine.setAttribute('y1', String(anchorScreenY - crossSize)); + vLine.setAttribute('x2', String(anchorScreenX)); + vLine.setAttribute('y2', String(anchorScreenY + crossSize)); + worldBoxLayer.appendChild(vLine); + } + + // Render responsive worldboxes + if (node.focusOptions.responsiveWorldBox && Array.isArray(node.focusOptions.responsiveWorldBox)) { + node.focusOptions.responsiveWorldBox.forEach((responsive, index) => { + const worldBox = responsive.minWorldBox; + if (!worldBox) return; + + const anchorX = responsive.anchorX ?? 0.5; + const anchorY = responsive.anchorY ?? 0.5; + const isActive = activeBreakpointIndex === index; + + // The viewport region is offset from the node center based on anchor. + const boxX = nodeCenterX - worldBox.width * anchorX; + const boxY = nodeCenterY - worldBox.height * anchorY; + + // Convert to screen space + const screenX = (boxX + offset.x) * zoom; + const screenY = (boxY + offset.y) * zoom; + const screenW = worldBox.width * zoom; + const screenH = worldBox.height * zoom; + + const opacity = isActive ? '1' : String(0.4 + (index * 0.15)); + + const rectEl = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rectEl.setAttribute('class', isActive ? 'worldbox-rect worldbox-rect--active' : 'worldbox-rect'); + rectEl.setAttribute('x', String(screenX)); + rectEl.setAttribute('y', String(screenY)); + rectEl.setAttribute('width', String(screenW)); + rectEl.setAttribute('height', String(screenH)); + rectEl.setAttribute('opacity', opacity); + worldBoxLayer.appendChild(rectEl); + + const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + textEl.setAttribute('class', isActive ? 'worldbox-label worldbox-label--active' : 'worldbox-label'); + textEl.setAttribute('x', String(screenX + 6)); + textEl.setAttribute('y', String(screenY + 14 + (index * 16))); + textEl.textContent = `@${responsive.minViewportWidth}px: ${worldBox.width}×${worldBox.height}`; + textEl.setAttribute('opacity', opacity); + worldBoxLayer.appendChild(textEl); + + // Draw crosshair at the node center (the focus point) + const anchorScreenX = (nodeCenterX + offset.x) * zoom; + const anchorScreenY = (nodeCenterY + offset.y) * zoom; + const crossSize = 8; + + const hLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + hLine.setAttribute('class', isActive ? 'worldbox-anchor worldbox-anchor--active' : 'worldbox-anchor'); + hLine.setAttribute('x1', String(anchorScreenX - crossSize)); + hLine.setAttribute('y1', String(anchorScreenY)); + hLine.setAttribute('x2', String(anchorScreenX + crossSize)); + hLine.setAttribute('y2', String(anchorScreenY)); + hLine.setAttribute('opacity', opacity); + worldBoxLayer.appendChild(hLine); + + const vLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + vLine.setAttribute('class', isActive ? 'worldbox-anchor worldbox-anchor--active' : 'worldbox-anchor'); + vLine.setAttribute('x1', String(anchorScreenX)); + vLine.setAttribute('y1', String(anchorScreenY - crossSize)); + vLine.setAttribute('x2', String(anchorScreenX)); + vLine.setAttribute('y2', String(anchorScreenY + crossSize)); + vLine.setAttribute('opacity', opacity); + worldBoxLayer.appendChild(vLine); + }); + } + }); +} + +// ─── Viewbox Visualizer ─────────────────────────────────────────────────────── + +/** + * Renders the current viewbox overlay showing visible world area + */ +function renderViewbox() { + if (!viewboxLayer || viewboxLayer.style.display === 'none') return; + + // Clear existing + viewboxLayer.innerHTML = ''; + + const viewInfo = nav.getViewInfo(); + const zoom = viewInfo.zoom; + const offset = nav.getEffectiveOffset(); + const viewbox = viewInfo.viewbox; + + // Convert viewbox (world coords) to screen space + const screenX = (viewbox.x + offset.x) * zoom; + const screenY = (viewbox.y + offset.y) * zoom; + const screenW = viewbox.width * zoom; + const screenH = viewbox.height * zoom; + + // Create rectangle + const rectEl = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rectEl.setAttribute('class', 'viewbox-rect'); + rectEl.setAttribute('x', String(screenX)); + rectEl.setAttribute('y', String(screenY)); + rectEl.setAttribute('width', String(screenW)); + rectEl.setAttribute('height', String(screenH)); + viewboxLayer.appendChild(rectEl); + + // Add label + const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + textEl.setAttribute('class', 'viewbox-label'); + textEl.setAttribute('x', String(screenX + 10)); + textEl.setAttribute('y', String(screenY + 20)); + textEl.textContent = `Viewbox (${Math.round(viewbox.width)}×${Math.round(viewbox.height)})`; + viewboxLayer.appendChild(textEl); +} + +// ─── Load graph from JSON ────────────────────────────────────────────────── + +const response = await fetch("data/graph.json"); +let graphData = await response.json(); + +// Load spatial audio +await audio.load('pop', 'data/pop.mp3'); + +graphData.comments?.forEach(c => nodeRend.addComment(new GraphComment(c))); +graphData.nodes.forEach(n => nodeRend.addNode(new GraphNode(n))); +graphData.connections.forEach(c => nodeRend.addConnection(new GraphConnection(c.from, c.to, c.kind, c.id))); + +// Load graph settings into properties panel +propertiesPanel.loadGraphSettings(graphData); + +// Attach a PrintBubble to every print-type node +graphData.nodes.forEach(n => { + if (n.type !== "print") return; + const el = nodeRend.getNodeElements().get(n.id); + if (el) printBubbles.set(n.id, new PrintBubble(el)); +}); + +for (const img of graphData.images ?? []) { + if (img.inline) { + // Fetch and inline SVG for direct style control + try { + const response = await fetch(img.src); + const svgText = await response.text(); + const parser = new DOMParser(); + const svgDoc = parser.parseFromString(svgText, 'image/svg+xml'); + const svgEl = svgDoc.documentElement; + + svgEl.classList.add('world-image'); + // Remove percentage dimensions, set explicit width + svgEl.removeAttribute('height'); + svgEl.setAttribute('width', String(img.width)); + // Preserve viewBox for aspect ratio + if (!svgEl.hasAttribute('viewBox')) { + const w = svgEl.getAttribute('width'); + const h = svgEl.getAttribute('height'); + if (w && h) svgEl.setAttribute('viewBox', `0 0 ${w} ${h}`); + } + svgEl.style.transform = `translate(${img.position.x}px, ${img.position.y}px)`; + svgEl.style.display = 'block'; + + // Apply cssStyle to all paths/shapes in SVG + const shapes = svgEl.querySelectorAll('path, rect, circle, ellipse, polygon, polyline'); + if (img.cssStyle) { + const styleProps = img.cssStyle.split(';').filter(s => s.trim()); + styleProps.forEach(prop => { + const [key, val] = prop.split(':').map(s => s.trim()); + if (!key || !val) return; + if (key === 'fill' || key === 'stroke' || key === 'stroke-width' || key === 'opacity') { + shapes.forEach(el => { + // Skip elements with fill="none" (background/artboard rects) + if (el.getAttribute('fill') === 'none') return; + el.style[key] = val; + }); + } else { + svgEl.style[key] = val; + } + }); + } else { + // Default fill to white if no style specified + shapes.forEach(el => { + if (el.getAttribute('fill') === 'none') return; // Skip background + if (!el.hasAttribute('fill') && !el.style.fill) { + el.style.fill = '#ffffff'; + } + }); + } + + worldLayer.prepend(svgEl); + dragger.registerImage(img.src, svgEl, { ...img.position }); + console.log('Inlined SVG:', img.src, svgEl); + } catch (err) { + console.error(`Failed to inline SVG: ${img.src}`, err); + } + } else { + // Standard img element + const el = document.createElement("img"); + el.className = "world-image"; + el.src = img.src; + el.width = img.width; + el.alt = ""; + el.style.transform = `translate(${img.position.x}px, ${img.position.y}px)`; + if (img.cssStyle) { + el.style.cssText += `;${img.cssStyle}`; + } + el.addEventListener("dragstart", (e) => e.preventDefault()); + worldLayer.prepend(el); + dragger.registerImage(img.src, el, { ...img.position }); + } +} + +graphData.nodes.forEach(n => dragger.registerNode(n.id)); + +// ─── Initial focus ───────────────────────────────────────────────────────── + +const settings = graphData.settings ?? {}; +const introDurationMs = settings.introDurationMs ?? 700; +const initialViewbox = settings.initialViewbox ?? { x: 0, y: 0, width: 1280, height: 720 }; + +const initialDraggable = settings.draggable ?? false; +dragger.enabled = initialDraggable; +pinConns.enabled = initialDraggable; +debugPanel.setDraggable(initialDraggable); + +// Snap to a tiny zoom first so the intro animation has somewhere to travel from +const introRect = { + x: initialViewbox.x + initialViewbox.width / 2 - 50, + y: initialViewbox.y + initialViewbox.height / 2 - 50, + width: 100, + height: 100, +}; +const anchorId = location.hash.slice(1); +const anchorNode = anchorId ? nodeRend.getNodes().find(n => n.id === anchorId) : null; +const anchorRect = anchorNode ? nodeRend.getNodeWorldRect(anchorNode.id) : null; + +// Seed the current history entry so popstate can restore it when navigating back +history.replaceState({ nodeId: anchorId || null }, ""); + +nav.focusOnRect(introRect); +// Initial connection render — one rAF so layout is measured after nodes are in the DOM. +requestAnimationFrame(() => connRend.render()); +if (anchorNode) { + // Wait for all markdown to finish loading so info nodes have their real height, + // then one rAF to let the browser measure layout before animating. + nodeRend.contentReady().then(() => requestAnimationFrame(() => { + const rect = nodeRend.getNodeWorldRect(anchorNode.id); + if (rect) { + nav.animateFocusOnRect(rect, { ...(anchorNode.focusOptions ?? {}), durationMs: introDurationMs }); + } else { + nav.animateFocusOnRect(initialViewbox, { paddingFraction: 0, durationMs: introDurationMs }); + } + setTimeout(() => { trackTransform = true; }, introDurationMs + 100); + })); +} else { + nav.animateFocusOnRect(initialViewbox, { paddingFraction: 0, durationMs: introDurationMs }); + setTimeout(() => { trackTransform = true; }, introDurationMs + 100); +} + +resetBtn.addEventListener("click", () => { + history.pushState({ nodeId: null }, "", location.pathname + location.search); + nav.animateFocusOnRect(initialViewbox, { paddingFraction: 0, durationMs: introDurationMs }); +}); + +window.addEventListener("popstate", (e) => { + const nodeId = (e.state?.nodeId ?? location.hash.slice(1)) || null; + if (nodeId) { + const node = nodeRend.getNodes().find(n => n.id === nodeId); + if (node) { + nodeRend.contentReady().then(() => requestAnimationFrame(() => { + const rect = nodeRend.getNodeWorldRect(node.id); + if (rect) nav.animateFocusOnRect(rect, { ...(node.focusOptions ?? {}), durationMs: introDurationMs }); + })); + return; + } + } + nav.animateFocusOnRect(initialViewbox, { paddingFraction: 0, durationMs: introDurationMs }); +}); + +draggableToggle.addEventListener("click", () => { + const isActive = draggableToggle.classList.toggle("active"); + dragger.enabled = isActive; + pinConns.enabled = isActive; +}); + +// Clear selection when clicking on canvas background +canvas.addEventListener("click", (e) => { + // Only clear if clicking directly on canvas (not on nodes or other elements) + if (e.target === canvas) { + dragger.clearSelection(); + propertiesPanel.loadNode(null); + } +}); + +// Initialize dragging as disabled +dragger.enabled = false; +pinConns.enabled = false; diff --git a/website/scripts/nodes/BindEventNode.js b/website/scripts/nodes/BindEventNode.js new file mode 100644 index 0000000..95166c8 --- /dev/null +++ b/website/scripts/nodes/BindEventNode.js @@ -0,0 +1,40 @@ +/** + * @file BindEventNode.js + * @UCLASS(BlueprintType, Blueprintable) + */ + +import { NodeBase } from './NodeBase.js'; +import { NodeRegistry } from './NodeRegistry.js'; + +/** + * @UCLASS(BlueprintType) + * Binds the OnMessage event on a ChatNode. When a message arrives, updates + * the username/message output pins and fires the event_out exec pin. + */ +export class BindEventNode extends NodeBase { + /** @UCLASS(BlueprintType) */ + static NodeType = "bind_event"; + + /** + * @UFUNCTION(BlueprintCallable) + * @param {import('./NodeExecutionContext.js').NodeExecutionContext} ctx + */ + async BlueprintCallable_Execute(ctx) { + const chatInstance = ctx.BlueprintPure_ResolveInput("target"); + const node = ctx.BlueprintPure_GetNode(); + + if (!chatInstance || typeof chatInstance.setOnMessage !== "function" || !node) return; + + chatInstance.setOnMessage((username, message) => { + const usernamePin = node.outputs.find(p => p.id === "username"); + const messagePin = node.outputs.find(p => p.id === "message"); + if (usernamePin) usernamePin.defaultValue = username; + if (messagePin) messagePin.defaultValue = message; + + // Fire event_out — async, fire-and-forget + ctx.BlueprintCallable_RunExecPin("event_out").catch(console.error); + }); + } +} + +NodeRegistry.UCLASS_Register(BindEventNode); diff --git a/website/scripts/nodes/ButtonNode.js b/website/scripts/nodes/ButtonNode.js new file mode 100644 index 0000000..34f9538 --- /dev/null +++ b/website/scripts/nodes/ButtonNode.js @@ -0,0 +1,43 @@ +/** + * @file ButtonNode.js + * @UCLASS(BlueprintType, Blueprintable) + */ + +import { NodeBase } from './NodeBase.js'; +import { NodeRegistry } from './NodeRegistry.js'; + +/** + * @UCLASS(BlueprintType) + * Renders a play button in the header that triggers node execution. + */ +export class ButtonNode extends NodeBase { + /** @UCLASS(BlueprintType) */ + static NodeType = "button"; + + /** + * @UFUNCTION(BlueprintNativeEvent) + * @param {HTMLElement} article + * @param {import('../GraphNode.js').GraphNode} graphNode + * @param {import('./NodeRenderContext.js').NodeRenderContext} renderCtx + */ + BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) { + const header = article.querySelector(".node-header"); + if (!header) return; + + const btn = document.createElement("button"); + btn.className = "node-run-btn"; + btn.title = "Execute"; + btn.innerHTML = ``; + + btn.addEventListener("click", (e) => { + e.stopPropagation(); + renderCtx.BlueprintCallable_TriggerNodeExecute(graphNode.id); + btn.classList.add("node-run-btn--flash"); + btn.addEventListener("animationend", () => btn.classList.remove("node-run-btn--flash"), { once: true }); + }); + + header.appendChild(btn); + } +} + +NodeRegistry.UCLASS_Register(ButtonNode); diff --git a/website/scripts/nodes/ChatConnectNode.js b/website/scripts/nodes/ChatConnectNode.js new file mode 100644 index 0000000..9a3c68c --- /dev/null +++ b/website/scripts/nodes/ChatConnectNode.js @@ -0,0 +1,29 @@ +/** + * @file ChatConnectNode.js + * @UCLASS(BlueprintType, Blueprintable) + */ + +import { NodeBase } from './NodeBase.js'; +import { NodeRegistry } from './NodeRegistry.js'; + +/** + * @UCLASS(BlueprintType) + * Calls connect() on a ChatNode instance received via the target input. + */ +export class ChatConnectNode extends NodeBase { + /** @UCLASS(BlueprintType) */ + static NodeType = "chat_connect"; + + /** + * @UFUNCTION(BlueprintCallable) + * @param {import('./NodeExecutionContext.js').NodeExecutionContext} ctx + */ + async BlueprintCallable_Execute(ctx) { + const chatInstance = ctx.BlueprintPure_ResolveInput("target"); + if (chatInstance && typeof chatInstance.connect === "function") { + chatInstance.connect(); + } + } +} + +NodeRegistry.UCLASS_Register(ChatConnectNode); diff --git a/website/scripts/nodes/CustomEventNode.js b/website/scripts/nodes/CustomEventNode.js new file mode 100644 index 0000000..eb751b8 --- /dev/null +++ b/website/scripts/nodes/CustomEventNode.js @@ -0,0 +1,49 @@ +/** + * @file CustomEventNode.js + * @UCLASS(BlueprintType, Blueprintable) + */ + +import { NodeBase } from './NodeBase.js'; +import { NodeRegistry } from './NodeRegistry.js'; + +const HAND_SVG = ``; + +/** + * @UCLASS(BlueprintType) + * Entry-point event node. Adds pulsate animation and a hand-pointer hint. + */ +export class CustomEventNode extends NodeBase { + /** @UCLASS(BlueprintType) */ + static NodeType = "custom_event"; + + /** + * @UFUNCTION(BlueprintNativeEvent) + * @param {HTMLElement} article + * @param {import('../GraphNode.js').GraphNode} graphNode + */ + BlueprintNativeEvent_OnRender(article, graphNode) { + const outputs = article.querySelector(".node-outputs"); + if (!outputs) return; + + const execPin = outputs.querySelector('[data-pin-id="exec_out"]'); + if (!execPin) return; + + execPin.classList.add("pin--pulsate"); + execPin.style.position = "relative"; + + const handHint = document.createElement("div"); + handHint.className = "pin-hint-hand"; + handHint.innerHTML = HAND_SVG; + handHint.style.color = "#fff"; + execPin.appendChild(handHint); + + // Dismiss hand permanently on first click of the pin (handle or label) + const dismiss = () => { + article.classList.add("hint-dismissed"); + execPin.removeEventListener("click", dismiss); + }; + execPin.addEventListener("click", dismiss); + } +} + +NodeRegistry.UCLASS_Register(CustomEventNode); diff --git a/website/scripts/nodes/GetMatrixChatNode.js b/website/scripts/nodes/GetMatrixChatNode.js new file mode 100644 index 0000000..aec2773 --- /dev/null +++ b/website/scripts/nodes/GetMatrixChatNode.js @@ -0,0 +1,38 @@ +/** + * @file GetMatrixChatNode.js + * @UCLASS(BlueprintType, Blueprintable) + */ + +import { NodeBase } from './NodeBase.js'; +import { NodeRegistry } from './NodeRegistry.js'; +import { GlobalVariables } from '../GlobalVariables.js'; + +/** + * @UCLASS(BlueprintType) + * Pure getter node that exposes the "MatrixChat" global as an object output. + * Styled with a semi-transparent blue header to visually denote a variable getter. + */ +export class GetMatrixChatNode extends NodeBase { + /** @UCLASS(BlueprintType) */ + static NodeType = "get_matrix_chat"; + + /** + * @UFUNCTION(BlueprintNativeEvent) + * @param {HTMLElement} article + * @param {import('../GraphNode.js').GraphNode} graphNode + */ + BlueprintNativeEvent_OnRender(article, graphNode) { + const header = article.querySelector(".node-header"); + if (header) { + header.style.background = "rgba(100, 181, 246, 0.2)"; + header.style.borderColor = "rgba(100, 181, 246, 0.4)"; + } + + const outputPin = graphNode.outputs.find(p => p.id === "value"); + if (outputPin && GlobalVariables.has("MatrixChat")) { + outputPin.defaultValue = GlobalVariables.get("MatrixChat"); + } + } +} + +NodeRegistry.UCLASS_Register(GetMatrixChatNode); diff --git a/website/scripts/nodes/InfoNode.js b/website/scripts/nodes/InfoNode.js new file mode 100644 index 0000000..9b5b936 --- /dev/null +++ b/website/scripts/nodes/InfoNode.js @@ -0,0 +1,35 @@ +/** + * @file InfoNode.js + * @UCLASS(BlueprintType, Blueprintable) + */ + +import { NodeBase } from './NodeBase.js'; +import { NodeRegistry } from './NodeRegistry.js'; + +/** + * @UCLASS(BlueprintType) + * Renders a markdown body below the node header. + */ +export class InfoNode extends NodeBase { + /** @UCLASS(BlueprintType) */ + static NodeType = "info"; + + /** + * @UFUNCTION(BlueprintNativeEvent) + * @param {HTMLElement} article + * @param {import('../GraphNode.js').GraphNode} graphNode + * @param {import('./NodeRenderContext.js').NodeRenderContext} renderCtx + */ + BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) { + if (!graphNode.markdownSrc) return; + + const body = document.createElement("div"); + body.className = "node-body"; + body.setAttribute("aria-live", "polite"); + article.appendChild(body); + + renderCtx.BlueprintCallable_LoadMarkdown(graphNode.markdownSrc, body); + } +} + +NodeRegistry.UCLASS_Register(InfoNode); diff --git a/website/scripts/nodes/LottieNode.js b/website/scripts/nodes/LottieNode.js new file mode 100644 index 0000000..1953788 --- /dev/null +++ b/website/scripts/nodes/LottieNode.js @@ -0,0 +1,43 @@ +/** + * @file LottieNode.js + * @UCLASS(BlueprintType, Blueprintable) + */ + +import { NodeBase } from './NodeBase.js'; +import { NodeRegistry } from './NodeRegistry.js'; + +/** + * @UCLASS(BlueprintType) + * Renders a Lottie animation JSON file inside a node body using the + * `` web component (loaded via CDN in index.html). + * Set `lottieSrc` on the graph node data to point at a Lottie JSON file. + */ +export class LottieNode extends NodeBase { + /** @type {string} */ + static NodeType = "lottie"; + + /** + * @UFUNCTION(BlueprintNativeEvent) + * @param {HTMLElement} article + * @param {import('../GraphNode.js').GraphNode} graphNode + */ + BlueprintNativeEvent_OnRender(article, graphNode) { + if (!graphNode.lottieSrc) return; + + const body = document.createElement("div"); + body.className = "node-body node-body--lottie"; + + const player = document.createElement("lottie-player"); + player.setAttribute("src", graphNode.lottieSrc); + player.setAttribute("background", "transparent"); + player.setAttribute("speed", "1"); + player.setAttribute("loop", ""); + player.setAttribute("autoplay", ""); + player.className = "node-lottie"; + + body.appendChild(player); + article.appendChild(body); + } +} + +NodeRegistry.UCLASS_Register(LottieNode); diff --git a/website/scripts/nodes/MatrixChatNode.js b/website/scripts/nodes/MatrixChatNode.js new file mode 100644 index 0000000..fe83523 --- /dev/null +++ b/website/scripts/nodes/MatrixChatNode.js @@ -0,0 +1,84 @@ +/** + * @file MatrixChatNode.js + * @UCLASS(BlueprintType, Blueprintable) + */ + +import { NodeBase } from './NodeBase.js'; +import { NodeRegistry } from './NodeRegistry.js'; +import { ChatNode } from '../ChatNode.js'; +import { GlobalVariables } from '../GlobalVariables.js'; + +/** + * @UCLASS(BlueprintType) + * Renders a full Matrix chat UI inside the node body. + * Instantiates a ChatNode and registers it as the "MatrixChat" global. + */ +export class MatrixChatNode extends NodeBase { + /** @UCLASS(BlueprintType) */ + static NodeType = "chat"; + + /** + * @UFUNCTION(BlueprintNativeEvent) + * @param {HTMLElement} article + * @param {import('../GraphNode.js').GraphNode} graphNode + * @param {import('./NodeRenderContext.js').NodeRenderContext} renderCtx + */ + BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) { + const body = document.createElement("div"); + body.className = "node-body node-body--chat"; + + const statusBar = document.createElement("div"); + statusBar.className = "chat-status-bar"; + + const statusEl = document.createElement("span"); + statusEl.className = "chat-status chat-status-disconnected"; + statusEl.textContent = "Not connected"; + statusBar.appendChild(statusEl); + + const messagesContainer = document.createElement("div"); + messagesContainer.className = "chat-messages"; + + const inputContainer = document.createElement("div"); + inputContainer.className = "chat-input-container"; + + const messageInput = document.createElement("input"); + messageInput.type = "text"; + messageInput.className = "chat-input"; + messageInput.placeholder = "Type a message or /nick..."; + messageInput.addEventListener("focus", () => { + // On mobile the virtual keyboard shrinks the viewport. Delay lets the + // keyboard finish animating before we scroll the input into view. + window.setTimeout(() => { + messageInput.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }, 300); + }); + inputContainer.appendChild(messageInput); + + body.appendChild(statusBar); + body.appendChild(messagesContainer); + body.appendChild(inputContainer); + article.appendChild(body); + + const homeserverPin = graphNode.inputs.find(p => p.id === "homeserver"); + const roomPin = graphNode.inputs.find(p => p.id === "room"); + const homeserver = homeserverPin?.defaultValue || "https://matrix.org"; + const roomAlias = roomPin?.defaultValue || "#general:matrix.org"; + + const chatInstance = new ChatNode(homeserver, roomAlias); + // Pass resolver function so chat can resolve name at connect time + chatInstance.initialize( + messagesContainer, + messageInput, + statusEl, + inputContainer, + homeserver, + roomAlias, + () => renderCtx.BlueprintPure_ResolveInputValue?.(graphNode.id, "guestName") || "" + ); + + renderCtx.BlueprintCallable_RegisterChatInstance(graphNode.id, chatInstance); + GlobalVariables.set("MatrixChat", chatInstance); + } +} + +NodeRegistry.UCLASS_Register(MatrixChatNode); diff --git a/website/scripts/nodes/NodeBase.js b/website/scripts/nodes/NodeBase.js new file mode 100644 index 0000000..919d410 --- /dev/null +++ b/website/scripts/nodes/NodeBase.js @@ -0,0 +1,53 @@ +/** + * @file NodeBase.js + * Abstract base class for all Blueprint graph node types. + * + * Method prefixes mirror Unreal Engine UFUNCTION macro conventions: + * BlueprintNativeEvent_ — has a native default implementation; subclasses may override + * BlueprintCallable_ — has exec-pin side effects; called during graph traversal + * BlueprintPure_ — side-effect free; does not advance exec flow + */ + +/** + * @typedef {import('../GraphNode.js').GraphNode} GraphNode + * @typedef {import('./NodeRenderContext.js').NodeRenderContext} NodeRenderContext + * @typedef {import('./NodeExecutionContext.js').NodeExecutionContext} NodeExecutionContext + */ + +/** + * @UCLASS(Abstract) + * Base class all node handlers inherit from. Registered via NodeRegistry. + */ +export class NodeBase { + /** + * The graph type string that maps to this class in the registry. + * Must be overridden by every subclass. + * @UCLASS(BlueprintType) + * @type {string} + */ + static NodeType = ""; + + /** + * Called after the node's base DOM (header, pins) has been built. + * Override to inject type-specific UI into the article element. + * + * @UFUNCTION(BlueprintNativeEvent) + * @param {HTMLElement} article + * @param {GraphNode} graphNode + * @param {NodeRenderContext} renderCtx + */ + // eslint-disable-next-line no-unused-vars + BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) {} + + /** + * Called when this node is reached during exec graph traversal. + * Override to implement node-specific behaviour. + * Exec flow continues automatically after this returns. + * + * @UFUNCTION(BlueprintCallable) + * @param {NodeExecutionContext} ctx + * @returns {Promise} + */ + // eslint-disable-next-line no-unused-vars + async BlueprintCallable_Execute(ctx) {} +} diff --git a/website/scripts/nodes/NodeExecutionContext.js b/website/scripts/nodes/NodeExecutionContext.js new file mode 100644 index 0000000..33e4b71 --- /dev/null +++ b/website/scripts/nodes/NodeExecutionContext.js @@ -0,0 +1,113 @@ +/** + * @file NodeExecutionContext.js + * Runtime context passed to node handlers during graph traversal. + */ + +/** + * @typedef {import('../GraphNode.js').GraphNode} GraphNode + * @typedef {import('../GraphConnection.js').GraphConnection} GraphConnection + * @typedef {import('../NodeRenderer.js').NodeRenderer} NodeRenderer + */ + +/** + * Provides controlled access to runtime execution services. + * Passed to BlueprintCallable_Execute on every node handler invocation. + */ +export class NodeExecutionContext { + /** @type {string} */ + #nodeId; + + /** @type {NodeRenderer} */ + #nodeRenderer; + + /** @type {(nodeId: string, pinId: string) => any} */ + #resolveInputFn; + + /** @type {(nodeId: string, pinId: string) => Promise} */ + #runExecPinFn; + + /** @type {((nodeId: string, value: string) => void) | null} */ + #onPrint; + + /** @type {((connId: string, kind: string) => Promise) | null} */ + #onStep; + + /** + * @param {string} nodeId + * @param {NodeRenderer} nodeRenderer + * @param {(nodeId: string, pinId: string) => any} resolveInputFn + * @param {(nodeId: string, pinId: string) => Promise} runExecPinFn + * @param {((nodeId: string, value: string) => void) | null} onPrint + * @param {((connId: string, kind: string) => Promise) | null} onStep + */ + constructor(nodeId, nodeRenderer, resolveInputFn, runExecPinFn, onPrint, onStep) { + this.#nodeId = nodeId; + this.#nodeRenderer = nodeRenderer; + this.#resolveInputFn = resolveInputFn; + this.#runExecPinFn = runExecPinFn; + this.#onPrint = onPrint; + this.#onStep = onStep; + } + + /** + * Returns the ID of the currently executing node. + * + * @UFUNCTION(BlueprintPure) + * @returns {string} + */ + BlueprintPure_GetNodeId() { + return this.#nodeId; + } + + /** + * Returns the GraphNode data for the currently executing node. + * + * @UFUNCTION(BlueprintPure) + * @returns {GraphNode | null} + */ + BlueprintPure_GetNode() { + return this.#nodeRenderer.getNodes().find(n => n.id === this.#nodeId) ?? null; + } + + /** + * Resolves the runtime value of a named input pin, following connections. + * + * @UFUNCTION(BlueprintPure) + * @param {string} pinId + * @returns {any} + */ + BlueprintPure_ResolveInput(pinId) { + return this.#resolveInputFn(this.#nodeId, pinId); + } + + /** + * Returns all live graph connections. + * + * @UFUNCTION(BlueprintPure) + * @returns {GraphConnection[]} + */ + BlueprintPure_GetConnections() { + return this.#nodeRenderer.getConnections(); + } + + /** + * Triggers exec flow from an output pin on this node. + * + * @UFUNCTION(BlueprintCallable) + * @param {string} pinId + * @returns {Promise} + */ + BlueprintCallable_RunExecPin(pinId) { + return this.#runExecPinFn(this.#nodeId, pinId); + } + + /** + * Fires the print callback with a string value. + * + * @UFUNCTION(BlueprintCallable) + * @param {string} value + */ + BlueprintCallable_Print(value) { + this.#onPrint?.(this.#nodeId, value); + } +} diff --git a/website/scripts/nodes/NodeRegistry.js b/website/scripts/nodes/NodeRegistry.js new file mode 100644 index 0000000..4ad336f --- /dev/null +++ b/website/scripts/nodes/NodeRegistry.js @@ -0,0 +1,50 @@ +/** + * @file NodeRegistry.js + * Static registry mapping node type strings to their handler instances. + */ + +import { NodeBase } from './NodeBase.js'; + +/** + * Static registry for all Blueprint node types. + * Node classes self-register by calling UCLASS_Register during module init. + */ +export class NodeRegistry { + /** @type {Map} */ + static #registry = new Map(); + + /** + * Registers a node class by its static NodeType. + * Call once per class, typically at the bottom of the class file. + * + * @UFUNCTION(BlueprintCallable) + * @param {typeof NodeBase} NodeClass + */ + static UCLASS_Register(NodeClass) { + if (!NodeClass.NodeType) { + throw new Error(`[NodeRegistry] "${NodeClass.name}" is missing a static NodeType.`); + } + this.#registry.set(NodeClass.NodeType, new NodeClass()); + } + + /** + * Returns the handler instance for a given node type, or null. + * + * @UFUNCTION(BlueprintPure) + * @param {string} nodeType + * @returns {NodeBase | null} + */ + static BlueprintPure_Get(nodeType) { + return this.#registry.get(nodeType) ?? null; + } + + /** + * Returns all currently registered type strings. + * + * @UFUNCTION(BlueprintPure) + * @returns {string[]} + */ + static BlueprintPure_GetRegisteredTypes() { + return [...this.#registry.keys()]; + } +} diff --git a/website/scripts/nodes/NodeRenderContext.js b/website/scripts/nodes/NodeRenderContext.js new file mode 100644 index 0000000..e43dc8f --- /dev/null +++ b/website/scripts/nodes/NodeRenderContext.js @@ -0,0 +1,137 @@ +/** + * @file NodeRenderContext.js + * Render-time context passed to node handlers when building their DOM. + */ + +/** + * @typedef {import('../GraphNode.js').GraphNode} GraphNode + */ + +/** + * Wraps all render-time services nodes may need during BlueprintNativeEvent_OnRender. + * Keeps NodeRenderer internals encapsulated while giving nodes controlled access. + */ +export class NodeRenderContext { + /** @type {(src: string, target: HTMLElement) => Promise} */ + #loadMarkdown; + + /** @type {(p: Promise) => void} */ + #addMarkdownPromise; + + /** @type {((nodeId: string, pinId?: string) => void) | null} */ + #onNodeExecute; + + /** @type {(nodeId: string, instance: any) => void} */ + #registerChatInstance; + + /** @type {(nodeId: string, toggleEl: HTMLButtonElement) => void} */ + #startTimer; + + /** @type {(nodeId: string) => void} */ + #stopTimer; + + /** @type {() => string} */ + #svgPlay; + + /** @type {() => string} */ + #svgStop; + + /** @type {(nodeId: string, pinId: string) => any} */ + #resolveInputValue; + + /** + * @param {{ + * loadMarkdown: (src: string, target: HTMLElement) => Promise, + * addMarkdownPromise: (p: Promise) => void, + * onNodeExecute: ((nodeId: string, pinId?: string) => void) | null, + * registerChatInstance: (nodeId: string, instance: any) => void, + * startTimer: (nodeId: string, toggleEl: HTMLButtonElement) => void, + * stopTimer: (nodeId: string) => void, + * svgPlay: () => string, + * svgStop: () => string, + * resolveInputValue: (nodeId: string, pinId: string) => any, + * }} callbacks + */ + constructor(callbacks) { + this.#loadMarkdown = callbacks.loadMarkdown; + this.#addMarkdownPromise = callbacks.addMarkdownPromise; + this.#onNodeExecute = callbacks.onNodeExecute; + this.#registerChatInstance = callbacks.registerChatInstance; + this.#startTimer = callbacks.startTimer; + this.#stopTimer = callbacks.stopTimer; + this.#svgPlay = callbacks.svgPlay; + this.#svgStop = callbacks.svgStop; + this.#resolveInputValue = callbacks.resolveInputValue; + } + + /** + * @UFUNCTION(BlueprintCallable) + * @param {string} src + * @param {HTMLElement} target + */ + BlueprintCallable_LoadMarkdown(src, target) { + this.#addMarkdownPromise(this.#loadMarkdown(src, target)); + } + + /** + * @UFUNCTION(BlueprintCallable) + * @param {string} nodeId + * @param {string} [pinId] + */ + BlueprintCallable_TriggerNodeExecute(nodeId, pinId) { + this.#onNodeExecute?.(nodeId, pinId); + } + + /** + * @UFUNCTION(BlueprintCallable) + * @param {string} nodeId + * @param {any} instance + */ + BlueprintCallable_RegisterChatInstance(nodeId, instance) { + this.#registerChatInstance(nodeId, instance); + } + + /** + * @UFUNCTION(BlueprintCallable) + * @param {string} nodeId + * @param {HTMLButtonElement} toggleEl + */ + BlueprintCallable_StartTimer(nodeId, toggleEl) { + this.#startTimer(nodeId, toggleEl); + } + + /** + * @UFUNCTION(BlueprintCallable) + * @param {string} nodeId + */ + BlueprintCallable_StopTimer(nodeId) { + this.#stopTimer(nodeId); + } + + /** + * @UFUNCTION(BlueprintPure) + * @returns {string} + */ + BlueprintPure_GetSvgPlay() { + return this.#svgPlay(); + } + + /** + * @UFUNCTION(BlueprintPure) + * @returns {string} + */ + BlueprintPure_GetSvgStop() { + return this.#svgStop(); + } + + /** + * @UFUNCTION(BlueprintPure) + * Resolves an input pin value by following connections. + * @param {string} nodeId + * @param {string} pinId + * @returns {any} + */ + BlueprintPure_ResolveInputValue(nodeId, pinId) { + return this.#resolveInputValue(nodeId, pinId); + } +} diff --git a/website/scripts/nodes/PrintNode.js b/website/scripts/nodes/PrintNode.js new file mode 100644 index 0000000..b69b1ed --- /dev/null +++ b/website/scripts/nodes/PrintNode.js @@ -0,0 +1,27 @@ +/** + * @file PrintNode.js + * @UCLASS(BlueprintType, Blueprintable) + */ + +import { NodeBase } from './NodeBase.js'; +import { NodeRegistry } from './NodeRegistry.js'; + +/** + * @UCLASS(BlueprintType) + * Logs a string value and fires it through the print callback. + */ +export class PrintNode extends NodeBase { + /** @UCLASS(BlueprintType) */ + static NodeType = "print"; + + /** + * @UFUNCTION(BlueprintCallable) + * @param {import('./NodeExecutionContext.js').NodeExecutionContext} ctx + */ + async BlueprintCallable_Execute(ctx) { + const value = ctx.BlueprintPure_ResolveInput("value"); + ctx.BlueprintCallable_Print(value); + } +} + +NodeRegistry.UCLASS_Register(PrintNode); diff --git a/website/scripts/nodes/PureNode.js b/website/scripts/nodes/PureNode.js new file mode 100644 index 0000000..8581991 --- /dev/null +++ b/website/scripts/nodes/PureNode.js @@ -0,0 +1,38 @@ +/** + * @file PureNode.js + * @UCLASS(BlueprintType, Blueprintable) + */ + +import { NodeBase } from './NodeBase.js'; +import { NodeRegistry } from './NodeRegistry.js'; + +/** + * @UCLASS(BlueprintType) + * Pure data node — optionally renders an image body. No exec logic. + */ +export class PureNode extends NodeBase { + /** @UCLASS(BlueprintType) */ + static NodeType = "pure"; + + /** + * @UFUNCTION(BlueprintNativeEvent) + * @param {HTMLElement} article + * @param {import('../GraphNode.js').GraphNode} graphNode + */ + BlueprintNativeEvent_OnRender(article, graphNode) { + if (!graphNode.imageSrc) return; + + const body = document.createElement("div"); + body.className = "node-body node-body--image"; + + const img = document.createElement("img"); + img.src = graphNode.imageSrc; + img.alt = ""; + img.className = "node-image"; + body.appendChild(img); + + article.appendChild(body); + } +} + +NodeRegistry.UCLASS_Register(PureNode); diff --git a/website/scripts/nodes/RandomNameNode.js b/website/scripts/nodes/RandomNameNode.js new file mode 100644 index 0000000..2a57eac --- /dev/null +++ b/website/scripts/nodes/RandomNameNode.js @@ -0,0 +1,124 @@ +/** + * @file RandomNameNode.js + * @UCLASS(BlueprintType, Blueprintable) + */ + +import { NodeBase } from './NodeBase.js'; +import { NodeRegistry } from './NodeRegistry.js'; + +/** + * @UCLASS(BlueprintType) + * Generates random names from first/second part combinations. + * Pre-runs on load and has a button to regenerate. + */ +export class RandomNameNode extends NodeBase { + /** @UCLASS(BlueprintType) */ + static NodeType = "random_name"; + + /** @type {Array} */ + static #firstParts = []; + + /** @type {Array} */ + static #secondParts = []; + + /** @type {Promise | null} */ + static #loadPromise = null; + + /** + * Load name parts from JSON + */ + static async #loadNameParts() { + if (this.#loadPromise) return this.#loadPromise; + + this.#loadPromise = (async () => { + try { + const response = await fetch('data/nameParts.json'); + const data = await response.json(); + this.#firstParts = data.firstParts || []; + this.#secondParts = data.secondParts || []; + } catch (err) { + console.error('Failed to load name parts:', err); + this.#firstParts = ['Random']; + this.#secondParts = ['Name']; + } + })(); + + return this.#loadPromise; + } + + /** + * Generate random name + * @returns {string} + */ + static generateName() { + if (this.#firstParts.length === 0 || this.#secondParts.length === 0) { + return 'Loading...'; + } + const first = this.#firstParts[Math.floor(Math.random() * this.#firstParts.length)]; + const second = this.#secondParts[Math.floor(Math.random() * this.#secondParts.length)]; + return `${first}${second}`; + } + + /** + * @UFUNCTION(BlueprintNativeEvent) + * @param {HTMLElement} article + * @param {import('../GraphNode.js').GraphNode} graphNode + * @param {import('./NodeRenderContext.js').NodeRenderContext} renderCtx + */ + async BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) { + await RandomNameNode.#loadNameParts(); + + // Generate initial name if not set + if (!graphNode._generatedName) { + graphNode._generatedName = RandomNameNode.generateName(); + } + + console.log('[RandomNameNode] Generated name:', graphNode._generatedName, 'for node', graphNode.id); + + // Add button to header + const header = article.querySelector(".node-header"); + if (header) { + const btn = document.createElement("button"); + btn.className = "node-run-btn"; + btn.title = "Generate New Name"; + btn.innerHTML = ``; + + btn.addEventListener("click", (e) => { + e.stopPropagation(); + graphNode._generatedName = RandomNameNode.generateName(); + nameDisplay.textContent = graphNode._generatedName; + + btn.classList.add("node-run-btn--flash"); + btn.addEventListener("animationend", () => btn.classList.remove("node-run-btn--flash"), { once: true }); + }); + + header.appendChild(btn); + } + + // Display name in body + const body = document.createElement("div"); + body.className = "node-body node-body--text"; + + const nameDisplay = document.createElement("div"); + nameDisplay.className = "node-name-display"; + nameDisplay.textContent = graphNode._generatedName; + + body.appendChild(nameDisplay); + article.appendChild(body); + } + + /** + * @UFUNCTION(BlueprintNativeEvent) + * @param {import('../GraphNode.js').GraphNode} graphNode + * @param {string} pinId + * @returns {any} + */ + BlueprintNativeEvent_GetOutputValue(graphNode, pinId) { + if (pinId === 'name') { + return graphNode._generatedName || ''; + } + return null; + } +} + +NodeRegistry.UCLASS_Register(RandomNameNode); diff --git a/website/scripts/nodes/SequenceNode.js b/website/scripts/nodes/SequenceNode.js new file mode 100644 index 0000000..e35dfa9 --- /dev/null +++ b/website/scripts/nodes/SequenceNode.js @@ -0,0 +1,36 @@ +/** + * @file SequenceNode.js + * @UCLASS(BlueprintType, Blueprintable) + */ + +import { NodeBase } from './NodeBase.js'; +import { NodeRegistry } from './NodeRegistry.js'; + +/** + * @UCLASS(BlueprintType) + * Fires all then_N outputs in order, then continues exec flow. + */ +export class SequenceNode extends NodeBase { + /** @UCLASS(BlueprintType) */ + static NodeType = "sequence"; + + /** + * @UFUNCTION(BlueprintCallable) + * @param {import('./NodeExecutionContext.js').NodeExecutionContext} ctx + */ + async BlueprintCallable_Execute(ctx) { + const node = ctx.BlueprintPure_GetNode(); + if (!node) return; + + // Fire every then_N output in order + const thenPins = node.outputs + .filter(p => p.id.startsWith("then_")) + .sort((a, b) => a.id.localeCompare(b.id)); + + for (const pin of thenPins) { + await ctx.BlueprintCallable_RunExecPin(pin.id); + } + } +} + +NodeRegistry.UCLASS_Register(SequenceNode); diff --git a/website/scripts/nodes/TimerNode.js b/website/scripts/nodes/TimerNode.js new file mode 100644 index 0000000..644c966 --- /dev/null +++ b/website/scripts/nodes/TimerNode.js @@ -0,0 +1,46 @@ +/** + * @file TimerNode.js + * @UCLASS(BlueprintType, Blueprintable) + */ + +import { NodeBase } from './NodeBase.js'; +import { NodeRegistry } from './NodeRegistry.js'; + +/** + * @UCLASS(BlueprintType) + * Renders a toggle button in the header that starts/stops an interval timer. + */ +export class TimerNode extends NodeBase { + /** @UCLASS(BlueprintType) */ + static NodeType = "timer"; + + /** + * @UFUNCTION(BlueprintNativeEvent) + * @param {HTMLElement} article + * @param {import('../GraphNode.js').GraphNode} graphNode + * @param {import('./NodeRenderContext.js').NodeRenderContext} renderCtx + */ + BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) { + const header = article.querySelector(".node-header"); + if (!header) return; + + const toggle = document.createElement("button"); + toggle.className = "node-timer-btn"; + toggle.title = "Start timer"; + toggle.innerHTML = renderCtx.BlueprintPure_GetSvgPlay(); + + toggle.addEventListener("click", (e) => { + e.stopPropagation(); + const running = toggle.dataset.running === "true"; + if (running) { + renderCtx.BlueprintCallable_StopTimer(graphNode.id); + } else { + renderCtx.BlueprintCallable_StartTimer(graphNode.id, toggle); + } + }); + + header.appendChild(toggle); + } +} + +NodeRegistry.UCLASS_Register(TimerNode); diff --git a/website/scripts/nodes/index.js b/website/scripts/nodes/index.js new file mode 100644 index 0000000..be74562 --- /dev/null +++ b/website/scripts/nodes/index.js @@ -0,0 +1,25 @@ +/** + * @file index.js + * Imports all node classes so they self-register with NodeRegistry. + * Import this module once at app startup before rendering any nodes. + */ + +export { NodeBase } from './NodeBase.js'; +export { NodeRegistry } from './NodeRegistry.js'; +export { NodeExecutionContext } from './NodeExecutionContext.js'; +export { NodeRenderContext } from './NodeRenderContext.js'; + +// Node types — each file calls NodeRegistry.UCLASS_Register on load +export { PrintNode } from './PrintNode.js'; +export { SequenceNode } from './SequenceNode.js'; +export { CustomEventNode } from './CustomEventNode.js'; +export { ButtonNode } from './ButtonNode.js'; +export { TimerNode } from './TimerNode.js'; +export { InfoNode } from './InfoNode.js'; +export { PureNode } from './PureNode.js'; +export { MatrixChatNode } from './MatrixChatNode.js'; +export { GetMatrixChatNode } from './GetMatrixChatNode.js'; +export { ChatConnectNode } from './ChatConnectNode.js'; +export { BindEventNode } from './BindEventNode.js'; +export { LottieNode } from './LottieNode.js'; +export { RandomNameNode } from './RandomNameNode.js'; diff --git a/website/sounds/551405__nakkivene66__old-pc-startup-idle-shutdown.wav b/website/sounds/551405__nakkivene66__old-pc-startup-idle-shutdown.wav deleted file mode 100644 index 5ce0e8c..0000000 Binary files a/website/sounds/551405__nakkivene66__old-pc-startup-idle-shutdown.wav and /dev/null differ diff --git a/website/sounds/floppyreadshort.wav b/website/sounds/floppyreadshort.wav deleted file mode 100644 index 991e698..0000000 Binary files a/website/sounds/floppyreadshort.wav and /dev/null differ diff --git a/website/sounds/poweroff.mp3 b/website/sounds/poweroff.mp3 deleted file mode 100644 index d5275b3..0000000 Binary files a/website/sounds/poweroff.mp3 and /dev/null differ diff --git a/website/sounds/poweron.mp3 b/website/sounds/poweron.mp3 deleted file mode 100644 index 5433278..0000000 Binary files a/website/sounds/poweron.mp3 and /dev/null differ diff --git a/website/sounds/ui_hacking_charenter_01.wav b/website/sounds/ui_hacking_charenter_01.wav deleted file mode 100644 index 3e11d7a..0000000 Binary files a/website/sounds/ui_hacking_charenter_01.wav and /dev/null differ diff --git a/website/sounds/ui_hacking_charenter_02.wav b/website/sounds/ui_hacking_charenter_02.wav deleted file mode 100644 index 8a2bf7f..0000000 Binary files a/website/sounds/ui_hacking_charenter_02.wav and /dev/null differ diff --git a/website/sounds/ui_hacking_charenter_03.wav b/website/sounds/ui_hacking_charenter_03.wav deleted file mode 100644 index cc3b9f4..0000000 Binary files a/website/sounds/ui_hacking_charenter_03.wav and /dev/null differ diff --git a/website/sounds/ui_hacking_charscroll.wav b/website/sounds/ui_hacking_charscroll.wav deleted file mode 100644 index 4e57d75..0000000 Binary files a/website/sounds/ui_hacking_charscroll.wav and /dev/null differ diff --git a/website/sounds/ui_hacking_charscroll_lp.wav b/website/sounds/ui_hacking_charscroll_lp.wav deleted file mode 100644 index f1ad62d..0000000 Binary files a/website/sounds/ui_hacking_charscroll_lp.wav and /dev/null differ diff --git a/website/sounds/ui_hacking_charsingle_01.wav b/website/sounds/ui_hacking_charsingle_01.wav deleted file mode 100644 index defadef..0000000 Binary files a/website/sounds/ui_hacking_charsingle_01.wav and /dev/null differ diff --git a/website/sounds/ui_hacking_charsingle_02.wav b/website/sounds/ui_hacking_charsingle_02.wav deleted file mode 100644 index c2ff37f..0000000 Binary files a/website/sounds/ui_hacking_charsingle_02.wav and /dev/null differ diff --git a/website/sounds/ui_hacking_charsingle_03.wav b/website/sounds/ui_hacking_charsingle_03.wav deleted file mode 100644 index 248958b..0000000 Binary files a/website/sounds/ui_hacking_charsingle_03.wav and /dev/null differ diff --git a/website/sounds/ui_hacking_charsingle_04.wav b/website/sounds/ui_hacking_charsingle_04.wav deleted file mode 100644 index d3ce789..0000000 Binary files a/website/sounds/ui_hacking_charsingle_04.wav and /dev/null differ diff --git a/website/sounds/ui_hacking_charsingle_05.wav b/website/sounds/ui_hacking_charsingle_05.wav deleted file mode 100644 index 6b72c63..0000000 Binary files a/website/sounds/ui_hacking_charsingle_05.wav and /dev/null differ diff --git a/website/sounds/ui_hacking_charsingle_06.wav b/website/sounds/ui_hacking_charsingle_06.wav deleted file mode 100644 index 917526f..0000000 Binary files a/website/sounds/ui_hacking_charsingle_06.wav and /dev/null differ diff --git a/website/styles.css b/website/styles.css deleted file mode 100644 index bdcc308..0000000 --- a/website/styles.css +++ /dev/null @@ -1,557 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: 'Courier New', Courier, monospace; - background: #001800; - color: #0f0; - overflow: hidden; - height: 100vh; - position: relative; -} - -/* Initial black screen fade-in */ -.page-fade-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: #000; - z-index: 20000; - pointer-events: none; - opacity: 1; - transition: opacity 0.8s ease-out; -} - -.page-fade-overlay.fade-out { - opacity: 0; -} - -/* Boot screen */ -.boot-screen { - position: absolute; - top: 80px; - left: 80px; - right: 80px; - bottom: 100px; - background: #001800; - z-index: 100; - padding: 20px; - font-family: 'Courier New', Courier, monospace; - font-size: 14px; - color: #aaa; - overflow: hidden; - transition: opacity 0.5s ease-out; - border: 3px solid #0f0; - border-radius: 8px; -} - -.boot-screen.fade-out { - opacity: 0; - pointer-events: none; -} - -.boot-screen pre { - margin: 0; - white-space: pre-wrap; - line-height: 1.4; -} - -.boot-screen .highlight { - color: #0f0; -} - -.boot-screen .white { - color: #fff; -} - -.crt-border-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - border: 80px solid transparent; - border-image-source: url('screenborder.png'); - border-image-slice: 200 95 fill; - border-image-width: 80px; - border-image-repeat: stretch; - pointer-events: none; - z-index: 9999; -} - -.container { - position: absolute; - top: 80px; - left: 80px; - right: 80px; - bottom:100px; - background: transparent; - border: 3px solid #0f0; - border-radius: 8px; - box-shadow: - 0 0 20px rgba(0, 255, 0, 0.3), - inset 0 0 50px rgba(0, 255, 0, 0.05); - display: flex; - flex-direction: column; - overflow: hidden; - z-index: 1; -} - -.terminal-header { - background: transparent; - border-bottom: 2px solid #0f0; - padding: 8px 16px; - display: flex; - justify-content: space-between; - align-items: center; - font-size: 14px; - font-weight: bold; - text-shadow: 0 0 5px #0f0; -} - -.header-left { - display: flex; - align-items: center; - gap: 20px; -} - -.header-links { - display: flex; - gap: 12px; - font-size: 12px; - font-weight: normal; -} - -.header-link { - color: #0f0; - text-decoration: none; - padding: 4px 8px; - border: 1px solid #0f0; - border-radius: 3px; - transition: all 0.2s; - text-shadow: 0 0 5px #0f0; -} - -.header-link:hover { - background: #0f0; - color: #001800; - box-shadow: 0 0 10px #0f0; -} - -.terminal-title { - letter-spacing: 2px; -} - -.system-time { - font-size: 12px; - opacity: 0.8; -} - -#terminal { - flex: 1; - padding: 5px 10px; - overflow: hidden; -} - -.xterm { - height: 100%; - padding: 0; -} - -.xterm-viewport { - background: transparent !important; -} - -.xterm-screen { - cursor: text; -} - -/* Hide xterm cursor completely */ -.xterm-cursor-layer { - display: none !important; -} - -/* Inline terminal input */ -.terminal-inline-input { - position: absolute; - background: transparent; - border: none; - outline: none; - color: #0f0; - font-family: 'Courier New', Courier, monospace; - font-size: inherit; - padding: 0; - margin: 0; - caret-color: #0f0; - text-shadow: 0 0 3px #0f0; - z-index: 10; - min-width: 50%; - max-width: 80%; - pointer-events: auto; -} - -/* Clickable commands in terminal */ -.xterm-screen a, -.xterm .xterm-link-layer a { - color: #0f0 !important; - text-decoration: underline !important; - cursor: pointer !important; - text-shadow: 0 0 5px #0f0; -} - -.xterm-screen a:hover, -.xterm .xterm-link-layer a:hover { - color: #0ff !important; - text-shadow: 0 0 8px #0ff !important; -} - -.terminal-inline-input::placeholder { - color: rgba(0, 255, 0, 0.3); -} - -.mention-suggestions { - position: absolute; - z-index: 11; - display: flex; - gap: 6px; - max-width: 85%; - padding: 1px 4px; - border: 1px solid rgba(0, 255, 0, 0.45); - background: rgba(0, 24, 0, 0.92); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - pointer-events: none; -} - -.mention-suggestion { - color: #66ff66; - opacity: 0.9; -} - -.mention-suggestion.selected { - color: #001800; - background: #66ff66; - padding: 0 2px; -} - -.status-bar { - background: transparent; - border-top: 2px solid #0f0; - padding: 6px 16px; - display: flex; - justify-content: space-between; - font-size: 12px; - text-shadow: 0 0 3px #0f0; -} - -.status-left { - font-weight: bold; -} - -.status-right { - opacity: 0.8; -} - -/* Quick command buttons */ -.quick-commands { - display: flex; - gap: 8px; - align-items: center; -} - -.quick-cmd { - background: transparent; - color: #0f0; - border: 1px solid #0f0; - border-radius: 3px; - padding: 2px 8px; - font-family: 'Courier New', Courier, monospace; - font-size: 11px; - cursor: pointer; - transition: all 0.2s; - text-shadow: 0 0 3px #0f0; -} - -.quick-cmd:hover { - background: #0f0; - color: #001800; - box-shadow: 0 0 8px #0f0; -} - -.quick-cmd:active { - transform: scale(0.95); -} - -/* CRT scanlines effect */ -.container::before { - content: " "; - display: block; - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), - linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06)); - z-index: 2; - background-size: 100% 2px, 3px 100%; - pointer-events: none; -} - -/* Subtle screen flicker */ -@keyframes flicker { - 0% { opacity: 0.98; } - 50% { opacity: 1; } - 100% { opacity: 0.98; } -} - -.container { - animation: flicker 1s infinite; -} - -/* Responsive adjustments */ -@media (max-width: 768px) { - .crt-border-overlay { - border-width: 40px; - border-image-width: 40px; - } - - .container, - .boot-screen { - top: 40px; - left: 40px; - right: 40px; - bottom: 50px; - border-radius: 4px; - } - - .terminal-header { - padding: 6px 12px; - font-size: 12px; - } - - .header-links { - gap: 8px; - font-size: 10px; - } - - .header-link { - padding: 3px 6px; - } - - .system-time { - font-size: 10px; - } - - .status-bar { - padding: 4px 12px; - font-size: 10px; - } - - .quick-commands { - gap: 6px; - } - - .quick-cmd { - padding: 2px 6px; - font-size: 10px; - } -} - -@media (max-width: 480px) { - .crt-border-overlay { - border-width: 15px; - border-image-width: 15px; - } - - .container, - .boot-screen { - top: 15px; - left: 15px; - right: 15px; - bottom: 20px; - border-width: 2px; - } - - .boot-screen { - font-size: 10px; - padding: 8px; - } - - .terminal-header { - padding: 4px 8px; - } - - .terminal-title { - letter-spacing: 0; - font-size: 9px; - } - - .header-left { - flex-direction: row; - align-items: center; - gap: 6px; - } - - .header-links { - gap: 4px; - font-size: 8px; - } - - .header-link { - padding: 2px 4px; - } - - .header-right { - display: none; - } - - .status-bar { - padding: 3px 8px; - font-size: 9px; - } - - .status-right { - display: none; - } - - #terminal { - padding: 3px 5px; - } - - /* Prevent iOS auto-zoom on input focus */ - .terminal-inline-input { - font-size: 16px; - } -} - -/* Very small screens - minimal border */ -@media (max-width: 380px) { - .crt-border-overlay { - border-width: 8px; - border-image-width: 8px; - } - - .container, - .boot-screen { - top: 8px; - left: 8px; - right: 8px; - bottom: 12px; - border-width: 1px; - border-radius: 4px; - } - - .terminal-header { - padding: 3px 6px; - } - - .terminal-title { - font-size: 8px; - } - - .header-links { - display: none; - } - - .status-bar { - padding: 2px 6px; - } -} - -/* Very skinny screens - no border at all */ -@media (max-width: 350px) { - .crt-border-overlay { - display: none; - } - - .container, - .boot-screen { - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 0; - border: none; - } - - .terminal-header { - border-bottom: 1px solid #0f0; - } - - .status-bar { - border-top: 1px solid #0f0; - } -} - -/* Keyboard open on mobile - detected via JS */ -body.keyboard-open .crt-border-overlay { - display: none; -} - -body.keyboard-open .container, -body.keyboard-open .boot-screen { - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 0; - border: none; -} - -body.keyboard-open .terminal-header { - border-bottom: 1px solid #0f0; - padding: 3px 8px; -} - -body.keyboard-open .status-bar { - border-top: 1px solid #0f0; - padding: 3px 8px; -} - -/* Small viewport (under 1280x720) - detected via JS */ -body.small-viewport .crt-border-overlay { - display: none; -} - -body.small-viewport .container, -body.small-viewport .boot-screen { - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 0; - border: none; -} - -body.small-viewport .terminal-header { - border-bottom: 1px solid #0f0; -} - -body.small-viewport .status-bar { - border-top: 1px solid #0f0; -} - -/* Scrollbar styling */ -::-webkit-scrollbar { - width: 8px; -} - -::-webkit-scrollbar-track { - background: #001800; -} - -::-webkit-scrollbar-thumb { - background: #0f0; - border-radius: 4px; -} - -::-webkit-scrollbar-thumb:hover { - background: #0c0; -} diff --git a/website/styles/index.html b/website/styles/index.html new file mode 100644 index 0000000..a40c94d --- /dev/null +++ b/website/styles/index.html @@ -0,0 +1,281 @@ + + + + + + Personal Site + + + +
    + +
    +
    +
    + +
    + +
    + + +
    + + + + + + + + + + + + + + + + + + diff --git a/website/styles/main.css b/website/styles/main.css new file mode 100644 index 0000000..bbff420 --- /dev/null +++ b/website/styles/main.css @@ -0,0 +1,2076 @@ +/* ─── Reset ─────────────────────────────────────────────────────────────────── */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + user-select: none; +} + +img { + -webkit-user-drag: none; + user-drag: none; +} + +/* ─── Custom Properties ──────────────────────────────────────────────────────── */ + +:root { + --bg: #101218; + --grid-size: 20px; + --grid-color: rgba(255, 255, 255, 0.04); + --grid-color-strong: rgba(255, 255, 255, 0.1); + --workspace-zoom: 1; + + --hud-bg: rgba(20, 20, 28, 0.75); + --hud-border: rgba(255, 255, 255, 0.08); + --hud-text: rgba(255, 255, 255, 0.7); + --hud-hover: rgba(255, 255, 255, 0.12); +} + +/* ─── Base ────────────────────────────────────────────────────────────────────── */ + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + background: var(--bg); + font-family: system-ui, sans-serif; + color: #fff; +} + +body { + display: flex; + flex-direction: row; +} + +/* ─── Workspace canvas ────────────────────────────────────────────────────────── */ + +.workspace-canvas { + --workspace-zoom: 1; + position: relative; + flex: 1; + overflow: hidden; + cursor: grab; + touch-action: none; + + background-image: + linear-gradient(var(--grid-color-strong) 2px, transparent 2px), + linear-gradient(90deg, var(--grid-color-strong) 2px, transparent 2px), + linear-gradient(var(--grid-color) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-color) 1px, transparent 1px); + + background-size: + calc(var(--grid-size) * var(--workspace-zoom) * 5) calc(var(--grid-size) * var(--workspace-zoom) * 5), + calc(var(--grid-size) * var(--workspace-zoom) * 5) calc(var(--grid-size) * var(--workspace-zoom) * 5), + calc(var(--grid-size) * var(--workspace-zoom)) calc(var(--grid-size) * var(--workspace-zoom)), + calc(var(--grid-size) * var(--workspace-zoom)) calc(var(--grid-size) * var(--workspace-zoom)); + + background-repeat: repeat; + background-position: 0 0, 0 0, 0 0, 0 0; + background-color: var(--bg); +} + +.workspace-canvas:active { + cursor: grabbing; +} + +/* ─── World layer ─────────────────────────────────────────────────────────────── */ + +.world-layer { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; + pointer-events: none; +} + +.world-image { + position: absolute; + display: block; +} + +.world-image[src$=".png"], +.world-image[src$=".jpg"], +.world-image[src$=".jpeg"], +.world-image[src$=".gif"] { + image-rendering: pixelated; +} + +.world-image svg, +.world-image[src$=".svg"] { + shape-rendering: geometricPrecision; +} + +svg.world-image { + shape-rendering: geometricPrecision; +} + +/* ─── Debug panel ─────────────────────────────────────────────────────────────── */ + +.debug-panel { + display: none; + position: absolute; + top: 16px; + right: 16px; + z-index: 200; + min-width: 240px; + background: rgba(10, 11, 16, 0.88); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 10px 12px; + backdrop-filter: blur(10px); + font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace; + font-size: 11px; + color: rgba(255, 255, 255, 0.55); + flex-direction: column; + gap: 3px; + user-select: none; + transition: min-width 0.2s ease; +} + +.debug-panel.collapsed { + min-width: auto; +} + +.debug-panel.collapsed > *:not(.debug-header) { + display: none; +} + +.debug-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} + +.debug-title { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.3); +} + +.debug-toggle-btn { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.4); + cursor: pointer; + border-radius: 4px; + transition: all 0.15s ease; +} + +.debug-toggle-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.7); +} + +.debug-toggle-btn:active { + transform: scale(0.95); +} + +.debug-eye-icon { + display: block; +} + +.debug-panel.collapsed .eye-visible { + display: none; +} + +.debug-panel.collapsed .eye-hidden { + display: block; +} + +.debug-row { + display: flex; + align-items: baseline; + gap: 6px; + min-height: 16px; +} + +.debug-check-row { + align-items: center; + cursor: pointer; +} + +.debug-label { + flex: 0 0 54px; + color: rgba(255, 255, 255, 0.35); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +} + +.debug-value { + color: rgba(180, 220, 255, 0.85); + white-space: nowrap; +} + +.debug-divider { + height: 1px; + background: rgba(255, 255, 255, 0.07); + margin: 4px 0; +} + +.debug-draggable-cb { + margin: 0; + accent-color: rgba(100, 160, 255, 0.9); + cursor: pointer; +} + +.debug-copy-btn { + margin-left: auto; + padding: 1px 6px; + font-family: inherit; + font-size: 10px; + background: rgba(255, 255, 255, 0.08); + color: rgba(180, 220, 255, 0.85); + border: 1px solid rgba(255, 255, 255, 0.13); + border-radius: 4px; + cursor: pointer; + white-space: nowrap; +} + +.debug-copy-btn:hover { + background: rgba(255, 255, 255, 0.15); +} + +.debug-copy-json-btn { + width: 100%; + padding: 8px 12px; + margin: 4px 0; + font-family: inherit; + font-size: 11px; + font-weight: 500; + background: rgba(100, 160, 255, 0.12); + color: rgba(180, 220, 255, 0.95); + border: 1px solid rgba(100, 160, 255, 0.25); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; +} + +.debug-copy-json-btn:hover { + background: rgba(100, 160, 255, 0.2); + color: rgba(200, 230, 255, 1); +} + +.debug-copy-json-btn:active { + transform: scale(0.98); +} + +.debug-show-properties-btn { + width: 100%; + padding: 8px 12px; + margin: 4px 0; + font-family: inherit; + font-size: 11px; + font-weight: 500; + background: rgba(160, 100, 255, 0.12); + color: rgba(200, 180, 255, 0.95); + border: 1px solid rgba(160, 100, 255, 0.25); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; +} + +.debug-show-properties-btn:hover { + background: rgba(160, 100, 255, 0.2); + color: rgba(220, 200, 255, 1); +} + +.debug-show-properties-btn:active { + transform: scale(0.98); +} + +.debug-subtitle { + font-size: 9px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.25); + margin-top: 4px; + margin-bottom: 2px; +} + +.debug-input { + flex: 1; + font-family: inherit; + font-size: 11px; + background: rgba(255, 255, 255, 0.05); + color: rgba(180, 220, 255, 0.9); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 3px; + padding: 2px 5px; + outline: none; + min-width: 0; +} + +.debug-input:focus { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(100, 160, 255, 0.5); +} + +.debug-input:read-only { + color: rgba(255, 255, 255, 0.4); + cursor: not-allowed; +} + +.debug-node-editor { + display: flex; + flex-direction: column; + gap: 3px; +} + +.debug-pins-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 4px; +} + +.debug-pin-item { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 4px; + padding: 4px; + display: flex; + flex-direction: column; + gap: 3px; +} + +.debug-pin-row { + display: flex; + gap: 3px; + align-items: center; +} + +.debug-pin-input { + flex: 1; + font-family: inherit; + font-size: 10px; + background: rgba(255, 255, 255, 0.05); + color: rgba(180, 220, 255, 0.9); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 3px; + padding: 2px 4px; + outline: none; + min-width: 0; +} + +.debug-pin-input:focus { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(100, 160, 255, 0.5); +} + +.debug-pin-id { + max-width: 80px; +} + +.debug-pin-kind { + max-width: 70px; +} + +.debug-pin-remove { + flex: 0 0 auto; + width: 18px; + height: 18px; + padding: 0; + font-size: 14px; + line-height: 1; + background: rgba(255, 80, 80, 0.2); + color: rgba(255, 120, 120, 0.9); + border: 1px solid rgba(255, 80, 80, 0.3); + border-radius: 3px; + cursor: pointer; +} + +.debug-pin-remove:hover { + background: rgba(255, 80, 80, 0.3); +} + +.debug-add-btn { + font-family: inherit; + font-size: 10px; + padding: 3px 6px; + background: rgba(100, 160, 255, 0.15); + color: rgba(180, 220, 255, 0.9); + border: 1px solid rgba(100, 160, 255, 0.3); + border-radius: 4px; + cursor: pointer; + margin-bottom: 6px; +} + +.debug-add-btn:hover { + background: rgba(100, 160, 255, 0.25); +} + +.debug-worldbox-cb { + margin: 0; + accent-color: rgba(255, 180, 60, 0.9); + cursor: pointer; +} + +.debug-json-editor-btn { + width: 100%; + font-family: inherit; + font-size: 10px; + padding: 3px 8px; + background: rgba(100, 160, 255, 0.15); + color: rgba(180, 220, 255, 0.9); + border: 1px solid rgba(100, 160, 255, 0.3); + border-radius: 4px; + cursor: pointer; +} + +.debug-json-editor-btn:hover { + background: rgba(100, 160, 255, 0.25); +} + +/* ─── WorldBox Overlay ────────────────────────────────────────────────────────── */ + +.worldbox-layer { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 150; +} + +.worldbox-rect { + fill: none; + stroke: rgba(255, 180, 60, 0.6); + stroke-width: 2; + stroke-dasharray: 5, 5; +} + +.worldbox-rect--active { + stroke: rgba(255, 80, 180, 0.95); + stroke-width: 2.5; + stroke-dasharray: 5, 5; + fill: rgba(255, 80, 180, 0.06); +} + +.worldbox-label { + font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace; + font-size: 10px; + fill: rgba(255, 200, 100, 0.9); + text-shadow: 0 0 4px rgba(0, 0, 0, 0.8); +} + +.worldbox-label--active { + fill: rgba(255, 120, 210, 1); +} + +.worldbox-anchor { + stroke: rgba(255, 100, 60, 0.8); + stroke-width: 2; + stroke-linecap: round; +} + +.worldbox-anchor--active { + stroke: rgba(255, 80, 180, 0.95); +} + +.viewbox-layer { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 145; +} + +.viewbox-rect { + fill: rgba(100, 200, 255, 0.05); + stroke: rgba(100, 200, 255, 0.7); + stroke-width: 3; + stroke-dasharray: 8, 4; +} + +.viewbox-label { + font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace; + font-size: 11px; + font-weight: 600; + fill: rgba(150, 220, 255, 1); + text-shadow: 0 0 6px rgba(0, 0, 0, 0.9), 0 0 3px rgba(0, 0, 50, 0.8); +} + +/* ─── JSON Editor Modal ────────────────────────────────────────────────────────── */ + +/* ─── Quick Links ─────────────────────────────────────────────────────────────── */ + +.quick-links { + position: absolute; + top: 20px; + left: 20px; + display: flex; + gap: 20px; + z-index: 100; +} + +.quick-link { + color: rgba(255, 255, 255, 0.5); + text-decoration: none; + font-size: 13px; + font-weight: 500; + transition: color 0.2s; +} + +.quick-link:hover { + color: rgba(255, 255, 255, 0.9); +} + +/* ─── HUD ─────────────────────────────────────────────────────────────────────── */ + +.hud { + position: absolute; + bottom: 24px; + right: 24px; + display: flex; + flex-direction: column; + gap: 6px; + z-index: 100; +} + +.hud-btn { + width: 36px; + height: 36px; + border-radius: 8px; + border: 1px solid var(--hud-border); + background: var(--hud-bg); + color: var(--hud-text); + font-size: 18px; + cursor: pointer; + backdrop-filter: blur(8px); + transition: background 0.15s; + display: flex; + align-items: center; + justify-content: center; +} + +.hud-btn:hover { + background: var(--hud-hover); +} + +.hud-btn-drag { + width: 36px; + height: 36px; + border-radius: 8px; + border: 1px solid var(--hud-border); + background: var(--hud-bg); + color: var(--hud-text); + cursor: pointer; + backdrop-filter: blur(8px); + transition: background 0.15s, border-color 0.15s; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.hud-btn-drag:hover { + background: var(--hud-hover); +} + +.hud-btn-drag.active { + background: rgba(60, 120, 255, 0.9); + border-color: rgba(80, 140, 255, 0.5); +} + +.hud-btn-drag.active:hover { + background: rgba(80, 140, 255, 1); +} + +.hud-tooltip { + position: absolute; + right: calc(100% + 12px); + top: 50%; + transform: translateY(-50%); + background: rgba(20, 22, 28, 0.95); + color: rgba(255, 255, 255, 0.9); + padding: 8px 12px; + border-radius: 6px; + font-size: 13px; + white-space: nowrap; + pointer-events: none; + border: 1px solid rgba(255, 255, 255, 0.15); + backdrop-filter: blur(10px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + opacity: 0; + visibility: hidden; + transition: opacity 0.2s, visibility 0.2s; +} + +.hud-btn-drag.active .hud-tooltip { + opacity: 1; + visibility: visible; +} + +.hud-tooltip::after { + content: ''; + position: absolute; + right: -9px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-left: 9px solid rgba(20, 22, 28, 0.95); + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; +} + +.hud-tooltip::before { + content: ''; + position: absolute; + right: -10px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-left: 10px solid rgba(255, 255, 255, 0.15); + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + z-index: -1; +} + +/* ─── Node + Connection layers ───────────────────────────────────────────────── */ + +.connection-layer { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: hidden; +} + +.node-layer { + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} + +.node-drag-ghost { + position: absolute; + pointer-events: none; + border-radius: 8px; + border: 2px solid rgba(255, 127, 17, 0.6); + background: rgba(255, 127, 17, 0.07); + opacity: 0.5; + z-index: 5; + transition: transform 140ms cubic-bezier(0.22, 0.61, 0.36, 1); +} + +/* ─── Comment boxes ───────────────────────────────────────────────────────────── */ + +.comment-layer { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 0; +} + +.blueprint-comment { + position: absolute; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + pointer-events: auto; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 4px 16px rgba(0, 0, 0, 0.3); + z-index: 0; + overflow: hidden; +} + +.comment-background { + position: absolute; + inset: 0; + z-index: -1; +} + +.comment-title { + padding: 10px 16px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + color: #ffffff; + text-shadow: + 0 0 12px rgba(255, 255, 255, 0.4), + 0 1px 2px rgba(0, 0, 0, 0.8); + background: linear-gradient(180deg, + rgba(0, 0, 0, 0.3) 0%, + rgba(0, 0, 0, 0.2) 100%); + border-bottom: 1px solid rgba(255, 255, 255, 0.2); + position: relative; + z-index: 1; +} + +/* ─── Blueprint node ──────────────────────────────────────────────────────────── */ + +.blueprint-node { + position: absolute; + min-width: 220px; + background: #1d2029; + border: 1px solid #2f3544; + border-radius: 8px; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.35); + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: auto; + grid-template-areas: + 'header header' + 'inputs outputs' + 'body body'; + column-gap: 0.35rem; + overflow: visible; + pointer-events: auto; + transition: box-shadow 160ms ease, border-color 160ms ease; +} + +.blueprint-node:hover { + border-color: rgba(255, 127, 17, 0.15); +} + +/* ─── Pure node ──────────────────────────────────────────────────────────────── */ + +.blueprint-node[data-node-type="pure"] { + border-radius: 12px; + border-color: rgba(62, 229, 129, 0.3); + background: #1a2120; + min-width: 0; +} + +.blueprint-node[data-node-type="pure"]:hover { + border-color: rgba(62, 229, 129, 0.55); +} + +.blueprint-node[data-node-type="pure"] .node-header { + background: linear-gradient(90deg, rgba(62, 229, 129, 0.14), rgba(62, 229, 129, 0.06)); + border-bottom-color: rgba(62, 229, 129, 0.15); + color: #7effc0; + font-size: 0.78rem; + padding: 0.35rem 0.7rem; +} + +.node-body--image { + padding: 0; + border-top: 1px solid rgba(62, 229, 129, 0.12); + overflow: hidden; + line-height: 0; +} + +.node-image { + display: block; + width: 100%; + height: auto; + object-fit: contain; + image-rendering: pixelated; + pointer-events: none; +} + +.node-body--lottie { + padding: 0; + border-top: 1px solid rgba(62, 229, 129, 0.12); + overflow: hidden; + line-height: 0; +} + +.node-lottie { + display: block; + width: 100%; + pointer-events: none; + transform: scaleX(-1); +} + +.node-body--text { + padding: 12px 16px; + border-top: 1px solid rgba(62, 229, 129, 0.12); +} + +.node-name-display { + font-size: 14px; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + text-align: center; + letter-spacing: 0.5px; + padding: 4px 8px; + background: rgba(62, 229, 129, 0.08); + border-radius: 4px; + font-family: 'Courier New', monospace; +} + +/* Lottie nodes: no node chrome — background, border, shadow, header all removed */ +.blueprint-node[data-node-type="lottie"] { + background: transparent; + border: none; + box-shadow: none; + min-width: 0; + display: none; +} + +/* Only show lottie nodes on touch devices, hide permanently once the user moves */ +@media (hover: none) and (pointer: coarse) { + .blueprint-node[data-node-type="lottie"] { + display: grid; + transition: opacity 300ms ease; + } + + .has-transformed .blueprint-node[data-node-type="lottie"] { + opacity: 0; + pointer-events: none; + } +} + +.blueprint-node[data-node-type="lottie"]:hover { + border-color: transparent; +} + +.blueprint-node[data-node-type="lottie"] .node-header { + display: none; +} + +.blueprint-node[data-node-type="lottie"] .node-body--lottie { + border-top: none; +} + +/* ─── Button node ────────────────────────────────────────────────────────────── */ + +.blueprint-node[data-node-type="button"] { + border-color: rgba(255, 160, 60, 0.35); + background: #201e18; +} + +.blueprint-node[data-node-type="button"]:hover { + border-color: rgba(255, 160, 60, 0.6); +} + +.blueprint-node[data-node-type="button"] .node-header { + background: linear-gradient(90deg, rgba(255, 160, 60, 0.18), rgba(255, 160, 60, 0.06)); + border-bottom-color: rgba(255, 160, 60, 0.18); + color: #ffd080; +} + +.node-run-btn { + margin-left: auto; + width: 22px; + height: 22px; + border-radius: 50%; + border: none; + background: rgba(255, 160, 60, 0.75); + color: #1a1600; + line-height: 0; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 120ms ease, transform 80ms ease; + pointer-events: auto; +} + +.node-run-btn:hover { + background: rgba(255, 180, 80, 1); + transform: scale(1.12); +} + +.node-run-btn:active { + transform: scale(0.92); +} + +@keyframes node-run-flash { + 0% { box-shadow: 0 0 0 0 rgba(255, 180, 80, 0.8); } + 60% { box-shadow: 0 0 0 8px rgba(255, 180, 80, 0); } + 100% { box-shadow: 0 0 0 0 rgba(255, 180, 80, 0); } +} + +.node-run-btn--flash { + animation: node-run-flash 320ms ease-out forwards; +} + +/* ─── Print node ─────────────────────────────────────────────────────────────── */ + +.blueprint-node[data-node-type="print"] { + border-color: rgba(94, 196, 255, 0.25); + background: #181d22; +} + +.blueprint-node[data-node-type="print"]:hover { + border-color: rgba(94, 196, 255, 0.5); +} + +.blueprint-node[data-node-type="print"] .node-header { + background: linear-gradient(90deg, rgba(94, 196, 255, 0.14), rgba(94, 196, 255, 0.05)); + border-bottom-color: rgba(94, 196, 255, 0.15); + color: #a8e8ff; +} + +/* ─── Timer node ─────────────────────────────────────────────────────────────── */ + +.blueprint-node[data-node-type="timer"] { + border-color: rgba(180, 120, 255, 0.3); + background: #1c1a28; +} + +.blueprint-node[data-node-type="timer"]:hover { + border-color: rgba(180, 120, 255, 0.55); +} + +.blueprint-node[data-node-type="timer"] .node-header { + background: linear-gradient(90deg, rgba(180, 120, 255, 0.16), rgba(180, 120, 255, 0.06)); + border-bottom-color: rgba(180, 120, 255, 0.18); + color: #d4aaff; + border-top-left-radius: 7px; + border-top-right-radius: 7px; + overflow: hidden; +} + +.node-timer-btn { + margin-left: auto; + width: 22px; + height: 22px; + border-radius: 50%; + border: none; + background: rgba(180, 120, 255, 0.7); + color: #100a1a; + line-height: 0; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 120ms ease, transform 80ms ease; + pointer-events: auto; +} + +.node-timer-btn:hover { + background: rgba(200, 150, 255, 1); + transform: scale(1.12); +} + +.node-timer-btn:active { + transform: scale(0.92); +} + +.node-timer-btn[data-running="true"] { + background: rgba(255, 90, 90, 0.8); + color: #fff; + animation: timer-pulse 1s ease-in-out infinite; +} + +@keyframes timer-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 90, 90, 0.6); } + 50% { box-shadow: 0 0 0 6px rgba(255, 90, 90, 0); } +} + +/* ─── Number pin input chip ──────────────────────────────────────────────────── */ + +.pin-value--number { + font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace; + font-size: 0.7rem; + color: #c8a0ff; + background: rgba(180, 120, 255, 0.08); + border: 1px solid rgba(180, 120, 255, 0.25); + border-radius: 4px; + padding: 1px 5px; + width: 5ch; + text-align: center; + order: 1; + outline: none; + pointer-events: auto; + cursor: text; + -moz-appearance: textfield; + appearance: textfield; + transition: border-color 120ms ease, background 120ms ease; +} + +.pin-value--number::-webkit-outer-spin-button, +.pin-value--number::-webkit-inner-spin-button { + -webkit-appearance: none; +} + +.pin-value--number:focus { + border-color: rgba(180, 120, 255, 0.7); + background: rgba(180, 120, 255, 0.15); +} + +/* ─── Print speech bubble stack ─────────────────────────────────────────────── */ + +.print-bubble-stack { + position: absolute; + bottom: calc(100% + 10px); + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: 5px; + align-items: center; + pointer-events: none; + z-index: 20; + /* downward arrow tail */ +} + +.print-bubble-stack:not(:empty)::after { + content: ''; + position: absolute; + bottom: -7px; + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: rgba(94, 196, 255, 0.45); + border-bottom: none; +} + +.print-bubble-msg { + background: rgba(16, 26, 38, 0.96); + border: 1px solid rgba(94, 196, 255, 0.4); + border-radius: 7px; + color: #a8e8ff; + font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace; + font-size: 0.72rem; + padding: 4px 10px; + white-space: nowrap; + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5), 0 0 6px rgba(94, 196, 255, 0.08); + animation: bubble-enter 0.18s cubic-bezier(0.22, 0.61, 0.36, 1) both; +} + +.print-bubble-msg.is-fading { + animation: bubble-exit 0.5s ease-in forwards; +} + +@keyframes bubble-enter { + from { opacity: 0; transform: translateY(5px) scale(0.95); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes bubble-exit { + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(-4px); } +} + +.node-header { + grid-area: header; + background: linear-gradient(90deg, #1d2029, #242833); + padding: 0.5rem 0.75rem; + font-weight: 600; + font-size: 0.85rem; + letter-spacing: 0.02em; + border-bottom: 1px solid #2f3544; + color: #f6f9ff; + display: flex; + align-items: center; + gap: 0.5rem; + border-top-left-radius: 7px; + border-top-right-radius: 7px; + overflow: hidden; +} + +.node-io { + display: flex; + flex-direction: column; + gap: 2px; + padding: 0.35rem 0.25rem; +} + +.node-inputs { + grid-area: inputs; + align-items: flex-start; +} + +.node-outputs { + grid-area: outputs; + align-items: flex-end; +} + +/* ─── Pins ────────────────────────────────────────────────────────────────────── */ + +.pin { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + padding: 2px 4px; + border-radius: 4px; + position: relative; + cursor: default; +} + +.pin-label { + color: #a8b2cc; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.68rem; + order: 1; +} + +.pin-label.is-hidden { + display: none; +} + +/* Input: handle left, label right */ +.node-inputs .pin .pin-label { order: 2; } +.node-inputs .pin .pin-handle { order: 0; } + +/* Output: label left, handle right */ +.node-outputs .pin .pin-label { order: 0; text-align: right; } +.node-outputs .pin .pin-handle { order: 2; } + +.pin-handle { + display: inline-flex; + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid #2f3544; + background: #242833; + position: relative; + flex-shrink: 0; + order: 2; + margin: 3px 0; + color: var(--pin-kind-color, #8c919d); +} + +.pin-handle::after { + content: ''; + position: absolute; + inset: 3px; + border-radius: 50%; + background: currentColor; + transition: transform 120ms ease; +} + +/* Disconnected data pins */ +.pin.is-disconnected .pin-handle { + background: transparent; + border-color: currentColor; + width: 16px; + height: 16px; +} + +.pin.is-disconnected .pin-handle::after { + inset: 4px; + background: transparent; + box-shadow: inset 0 0 0 2.5px currentColor; +} + +.pin.is-disconnected:not([data-type='exec']) .pin-handle::after { + background: transparent; + box-shadow: none; + opacity: 0; +} + +/* Exec pin shape */ +.pin[data-type='exec'] .pin-handle { + width: 14px; + height: 14px; + border: none; + border-radius: 0; + background: transparent; + margin: 2px 0; +} + +.pin[data-type='exec'] .pin-handle::after { + inset: 0; + border-radius: 0; + clip-path: polygon(0% 0%, 60% 0%, 100% 50%, 60% 100%, 0% 100%); + background: currentColor; +} + +/* Disconnected exec pin */ +.pin[data-type='exec'].is-disconnected .pin-handle::after { + inset: 0; + background: currentColor; + box-shadow: none; + opacity: 1; +} + +/* Pulsating exec pin (Begin Play hint) */ +.pin--pulsate[data-type='exec'] .pin-handle::after { + animation: exec-pin-pulse 2s ease-in-out infinite; +} + +@keyframes exec-pin-pulse { + 0%, 100% { + filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.4)); + transform: scale(1); + } + 50% { + filter: drop-shadow(0 0 16px rgba(255, 255, 255, 1)) + drop-shadow(0 0 24px rgba(255, 255, 255, 0.6)); + transform: scale(1.2); + } +} + +/* Hand pointer hint */ +.pin-hint-hand { + position: absolute; + left: 0px; + top: 30px; + width: 24px; + height: 24px; + pointer-events: none; + opacity: 0.9; + animation: hand-bounce 2s ease-in-out infinite; + transition: opacity 300ms ease; +} + +.hint-dismissed .pin-hint-hand { + opacity: 0; +} + +@keyframes hand-bounce { + 0%, 100% { + transform: translateY(-4px); + } + 50% { + transform: translateY(0); + } +} + +.pin[data-type='exec'].is-disconnected .pin-handle::before { + content: ''; + position: absolute; + inset: 2.5px; + clip-path: polygon(0% 0%, 60% 0%, 100% 50%, 60% 100%, 0% 100%); + background: #1d2029; + pointer-events: none; + z-index: 1; +} + +/* Connected exec pin — solid fill */ +.pin[data-type='exec'].is-connected .pin-handle::before { + display: none; +} + +.pin.is-clickable { + cursor: pointer; +} + +.pin.is-clickable.is-disconnected { + cursor: default; +} + +.pin.is-clickable .pin-handle { + transition: transform 120ms ease, filter 120ms ease; +} + +/* ─── String pin default value chip ─────────────────────────────────────────── */ + +.pin-value { + font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace; + font-size: 0.7rem; + color: #ff66ff; + background: rgba(255, 102, 255, 0.08); + border: 1px solid rgba(255, 102, 255, 0.25); + border-radius: 4px; + padding: 1px 5px; + order: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 140px; + pointer-events: auto; + cursor: text; + outline: none; + min-width: 1ch; + transition: border-color 120ms ease, background 120ms ease; +} + +.pin-value:focus { + border-color: rgba(255, 102, 255, 0.7); + background: rgba(255, 102, 255, 0.15); + text-overflow: clip; +} + +/* In output rows the value chip sits between label and handle */ +.node-outputs .pin .pin-value { order: 1; } + +/* In input rows the value chip sits between handle and label */ +.node-inputs .pin .pin-value { order: 1; } + +.pin.is-clickable:hover:not(.is-disconnected) .pin-handle { + transform: scale(1.25); + filter: brightness(1.4); +} + +/* ─── Connection wire hover highlight ───────────────────────────────────────── */ + +#connectionLayer path { + transition: stroke-width 120ms ease, filter 120ms ease; +} + +#connectionLayer path[data-highlighted="true"] { + stroke-width: 4.5; + filter: brightness(1.5) drop-shadow(0 0 5px currentColor); +} + +/* ─── Node body (info / markdown) ───────────────────────────────────────────── */ + +.node-body { + grid-area: body; + padding: 0.75rem 0.9rem; + border-top: 1px solid #2f3544; + color: #c8d0e8; + font-size: 0.8rem; + line-height: 1.6; + user-select: text; + cursor: text; +} + +.node-body .md-h1, +.node-body .md-h2, +.node-body .md-h3 { + color: #f0f4ff; + font-weight: 700; + margin-bottom: 0.3em; + margin-top: 0.6em; + letter-spacing: 0.01em; +} + +.node-body .md-h1 { font-size: 1.4rem; } +.node-body .md-h2 { font-size: 0.88rem; } +.node-body .md-h3 { font-size: 0.82rem; } + +.node-body .md-p { + margin: 0.4em 0; +} + +.node-body .md-ul, +.node-body .md-ol { + margin: 0.3em 0 0.3em 1.1em; + padding: 0; +} + +.node-body .md-li { + margin: 0.15em 0; +} + +.node-body .md-blockquote { + border-left: 3px solid rgba(255, 160, 60, 0.5); + padding: 0.2em 0.7em; + margin: 0.4em 0; + color: rgba(200, 210, 230, 0.7); + font-style: italic; +} + +.node-body .md-code { + font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace; + font-size: 0.78em; + background: rgba(255, 255, 255, 0.07); + border-radius: 3px; + padding: 0.1em 0.35em; + color: #a8daff; +} + +.node-body .md-pre { + background: rgba(0, 0, 0, 0.35); + border: 1px solid #2f3544; + border-radius: 5px; + padding: 0.5em 0.7em; + overflow-x: auto; + margin: 0.4em 0; + font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace; + font-size: 0.76em; + color: #a8daff; +} + +.node-body .md-hr { + border: none; + border-top: 1px solid #2f3544; + margin: 0.6em 0; +} + +.node-body .md-link { + color: rgba(120, 200, 255, 0.9); + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.35em; +} + +.node-body .md-link:hover { + color: #fff; +} + +/* ─── Socials button links ────────────────────────────────────────────────────── */ + +.blueprint-node[data-node-id="socials"] .node-body { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + padding: 10px 12px; +} + +.blueprint-node[data-node-id="socials"] .node-body .md-p { + margin: 0; + padding: 0; + width: 100%; +} + +.blueprint-node[data-node-id="socials"] .node-body .md-link { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + padding: 9px 12px; + border-radius: 7px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + color: #fff; + text-decoration: none; + transition: background 0.15s, border-color 0.15s; + box-sizing: border-box; + font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace; + font-size: 0.82em; +} + +.blueprint-node[data-node-id="socials"] .node-body .md-link:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.18); + color: #fff; +} + +.blueprint-node[data-node-id="socials"] .node-body .md-link-icon { + width: 1.2em; + height: 1.2em; + flex-shrink: 0; +} + +.node-body .md-link-icon { + width: 1em; + height: 1em; + flex-shrink: 0; + vertical-align: middle; +} + +.node-body .md-error { + color: rgba(255, 100, 100, 0.7); + font-size: 0.75em; +} + +/* ─── Properties Panel (Right Side) ───────────────────────────────────────────── */ + +.properties-panel { + position: relative; + width: 320px; + height: 100vh; + background: rgba(12, 13, 18, 0.95); + border-left: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + z-index: 250; + display: flex; + flex-direction: column; + flex-shrink: 0; + transition: width 0.3s ease, margin-right 0.3s ease; +} + +.properties-panel.collapsed { + width: 0; + margin-right: 0; + overflow: hidden; +} + +.properties-panel-tab { + position: fixed; + right: 0; + top: 50%; + transform: translateY(-50%); + width: 40px; + height: 80px; + background: rgba(12, 13, 18, 0.95); + border: 1px solid rgba(255, 255, 255, 0.1); + border-right: none; + border-radius: 8px 0 0 8px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: rgba(255, 255, 255, 0.6); + transition: all 0.2s ease; + opacity: 0; + pointer-events: none; + z-index: 260; +} + +.properties-panel.collapsed .properties-panel-tab { + opacity: 1; + pointer-events: all; +} + +.properties-panel-tab:hover { + background: rgba(20, 22, 28, 0.98); + color: rgba(255, 255, 255, 0.9); + right: -2px; +} + +.properties-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(10, 11, 16, 0.6); +} + +.properties-title { + margin: 0; + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + letter-spacing: 0.02em; +} + +.properties-toggle-btn { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + padding: 6px; + border-radius: 4px; + transition: all 0.15s; + display: flex; + align-items: center; + justify-content: center; +} + +.properties-toggle-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.9); +} + +.properties-panel.collapsed .properties-toggle-btn svg { + transform: rotate(180deg); +} + +.properties-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +.properties-section { + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + background: rgba(255, 255, 255, 0.02); + cursor: default; +} + +.section-header.collapsible { + cursor: pointer; +} + +.section-header.collapsible:hover { + background: rgba(255, 255, 255, 0.04); +} + +.section-header h3 { + margin: 0; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(255, 255, 255, 0.7); +} + +.section-toggle { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.4); + font-size: 10px; + cursor: pointer; + padding: 4px; + transition: transform 0.2s; +} + +.section-header.collapsed .section-toggle { + transform: rotate(-90deg); +} + +.section-header.collapsed + .section-content { + display: none; +} + +.section-content { + padding: 12px 20px 20px; +} + +.prop-row { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 14px; +} + +.prop-label { + font-size: 11px; + font-weight: 500; + color: rgba(255, 255, 255, 0.55); + letter-spacing: 0.02em; +} + +.prop-input { + width: 100%; + font-family: ui-monospace, monospace; + font-size: 12px; + background: rgba(255, 255, 255, 0.05); + color: rgba(220, 230, 255, 0.95); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 5px; + padding: 7px 10px; + outline: none; + transition: all 0.15s; +} + +.prop-input:focus { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(100, 160, 255, 0.5); +} + +.prop-input:read-only { + opacity: 0.6; + cursor: not-allowed; +} + +.prop-checkbox { + width: 18px; + height: 18px; + accent-color: rgba(100, 160, 255, 0.9); + cursor: pointer; +} + +.prop-divider { + height: 1px; + background: rgba(255, 255, 255, 0.06); + margin: 16px 0; +} + +.prop-subtitle { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(255, 255, 255, 0.5); + margin: 0 0 12px; +} + +.prop-btn-add { + width: 100%; + font-family: inherit; + font-size: 12px; + padding: 8px 12px; + background: rgba(100, 160, 255, 0.15); + color: rgba(180, 220, 255, 0.9); + border: 1px dashed rgba(100, 160, 255, 0.3); + border-radius: 5px; + cursor: pointer; + margin-top: 8px; + transition: all 0.15s; +} + +.prop-btn-add:hover { + background: rgba(100, 160, 255, 0.25); + border-style: solid; +} + +.prop-btn-primary { + width: 100%; + font-family: inherit; + font-size: 12px; + font-weight: 600; + padding: 10px 16px; + background: rgba(60, 180, 100, 0.9); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; +} + +.prop-btn-primary:hover { + background: rgba(70, 200, 110, 1); +} + +.breakpoints-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.breakpoint-item { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + padding: 12px; +} + +.breakpoint-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.breakpoint-title { + font-size: 11px; + font-weight: 600; + color: rgba(255, 200, 100, 0.9); +} + +.breakpoint-remove { + background: rgba(255, 80, 80, 0.2); + color: rgba(255, 120, 120, 0.9); + border: 1px solid rgba(255, 80, 80, 0.3); + border-radius: 4px; + padding: 3px 8px; + font-size: 10px; + cursor: pointer; + transition: all 0.15s; +} + +.breakpoint-remove:hover { + background: rgba(255, 80, 80, 0.4); +} + +.no-selection { + padding: 40px 20px; + text-align: center; + color: rgba(255, 255, 255, 0.4); +} + +.no-selection p { + margin: 0; + font-size: 13px; +} + +@media (max-width: 768px) { + .properties-panel { + width: 100%; + max-width: 360px; + } +} + +/* ─── Chat Node Styles ─────────────────────────────────────────────────────────── */ + +.node-body--chat { + display: flex; + flex-direction: column; + gap: 0; + min-height: 300px; + max-height: 400px; + padding: 0; + max-width: none; + min-width: 100%; + background: rgba(0, 0, 0, 0.3); + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.chat-status-bar { + padding: 6px 12px; + background: rgba(0, 0, 0, 0.4); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + gap: 8px; +} + +.chat-status { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 3px 8px; + border-radius: 3px; +} + +.chat-status-connecting { + background: rgba(251, 191, 36, 0.2); + color: #fbbf24; +} + +.chat-status-connected { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; +} + +.chat-status-disconnected { + background: rgba(148, 163, 184, 0.2); + color: #94a3b8; +} + +.chat-status-error { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; + min-height: 0; +} + +.chat-messages::-webkit-scrollbar { + width: 6px; +} + +.chat-messages::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.2); +} + +.chat-messages::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; +} + +.chat-messages::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + +.chat-msg { + display: grid; + grid-template-columns: auto auto 1fr; + gap: 6px; + font-size: 12px; + line-height: 1.4; + padding: 4px 6px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.03); +} + +.chat-msg:hover { + background: rgba(255, 255, 255, 0.06); +} + +.chat-msg-time { + color: rgba(255, 255, 255, 0.4); + font-size: 10px; + font-family: 'Courier New', monospace; +} + +.chat-msg-sender { + color: rgba(100, 181, 246, 0.9); + font-weight: 600; +} + +.chat-msg-self .chat-msg-sender { + color: rgba(156, 39, 176, 0.9); +} + +.chat-msg-content { + color: rgba(255, 255, 255, 0.85); + word-wrap: break-word; + overflow-wrap: break-word; +} + +.chat-msg-edited { + grid-column: 3; + font-size: 10px; + color: rgba(255, 255, 255, 0.3); + font-style: italic; + margin-left: 4px; +} + +.chat-msg-deleted { + opacity: 0.4; +} + +.chat-msg-deleted .chat-msg-content::before { + content: "[deleted] "; + color: rgba(239, 68, 68, 0.8); +} + +.chat-msg-system { + font-size: 11px; + color: rgba(251, 191, 36, 0.8); + font-style: italic; + padding: 2px 6px; + text-align: center; +} + +.chat-msg-error { + font-size: 11px; + color: rgba(239, 68, 68, 0.9); + font-weight: 600; + padding: 2px 6px; + text-align: center; +} + +.chat-input-container { + display: flex; + gap: 6px; + padding: 8px; + background: rgba(0, 0, 0, 0.4); + border-top: 1px solid rgba(255, 255, 255, 0.1); + position: relative; +} + +.chat-input { + flex: 1; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + padding: 6px 10px; + color: #fff; + font-size: 12px; + font-family: inherit; + outline: none; + user-select: text; +} + +.chat-input:focus { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(100, 181, 246, 0.5); +} + +.chat-input::placeholder { + color: rgba(255, 255, 255, 0.3); +} + +.chat-send-btn { + background: rgba(100, 181, 246, 0.2); + border: 1px solid rgba(100, 181, 246, 0.4); + border-radius: 4px; + padding: 6px 14px; + color: #64b5f6; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.chat-send-btn:hover { + background: rgba(100, 181, 246, 0.3); + border-color: rgba(100, 181, 246, 0.6); +} + +.chat-send-btn:active { + transform: translateY(1px); +} + +.chat-send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.chat-connect-btn { + background: rgba(34, 197, 94, 0.2); + border: 1px solid rgba(34, 197, 94, 0.4); + border-radius: 4px; + padding: 6px 14px; + color: #22c55e; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.chat-connect-btn:hover { + background: rgba(34, 197, 94, 0.3); + border-color: rgba(34, 197, 94, 0.6); +} + +.chat-connect-btn:active { + transform: translateY(1px); +} + +.chat-connect-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Autocomplete Popup */ +.autocomplete-popup { + position: absolute; + bottom: 100%; + left: 8px; + right: 8px; + margin-bottom: 4px; + background: rgba(30, 30, 40, 0.98); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + max-height: 200px; + overflow-y: auto; + box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4); + z-index: 1000; +} + +.autocomplete-popup::-webkit-scrollbar { + width: 6px; +} + +.autocomplete-popup::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.2); +} + +.autocomplete-popup::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; +} + +.ac-item { + padding: 6px 10px; + cursor: pointer; + display: flex; + gap: 8px; + align-items: center; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.ac-item:last-child { + border-bottom: none; +} + +.ac-item:hover, +.ac-item.selected { + background: rgba(100, 181, 246, 0.2); +} + +.ac-label { + color: #64b5f6; + font-weight: 600; + font-size: 12px; +} + +.ac-hint { + color: rgba(255, 255, 255, 0.5); + font-size: 11px; + font-family: 'Courier New', monospace; +} + +.ac-desc { + color: rgba(255, 255, 255, 0.6); + font-size: 11px; + flex: 1; + text-align: right; +} + diff --git a/website/terminal.js b/website/terminal.js deleted file mode 100644 index 6374fd6..0000000 --- a/website/terminal.js +++ /dev/null @@ -1,1604 +0,0 @@ -// Import commands -import helpCmd from './scripts/commands/help.js'; -import aboutCmd from './scripts/commands/about.js'; -import clearCmd from './scripts/commands/clear.js'; -import echoCmd from './scripts/commands/echo.js'; -import dateCmd from './scripts/commands/date.js'; -import whoamiCmd from './scripts/commands/whoami.js'; -import historyCmd from './scripts/commands/history.js'; -import colorCmd from './scripts/commands/color.js'; -import bannerCmd from './scripts/commands/banner.js'; -import githubCmd from './scripts/commands/github.js'; -import contactCmd from './scripts/commands/contact.js'; -import privacyCmd from './scripts/commands/privacy.js'; -import donateCmd from './scripts/commands/donate.js'; -import blueskyCmd from './scripts/commands/bluesky.js'; -import numbermatchCmd, { gameMode, processGameInput, handleTileClick } from './scripts/commands/numbermatch.js'; -import samsayCmd from './scripts/commands/samsay.js'; - -// Import Matrix client -import { - initMatrixClient, - updateClientSession, - chatMode, - matrixApi, - fetchPublicLastMessage, - fetchPublicPresence, - formatTimeAgo, - isDisplayNameTaken, - hasVisibleMentionSuggestions, - resetMentionAutocomplete, - refreshMentionSuggestionsFromInput, - commitSelectedMentionSuggestion, - applyMentionAutocomplete, - enterChatMode, - exitChatMode, - updateQuickCommands, - runChatCommand, - runGameCommand, - renderChatPrompt, - sendChatMessage -} from './matrix-client.js'; - -/** - * SAM (Software Automatic Mouth) speech synthesizer instance - * @type {object|null} - */ -let sam = null; - -/** - * Initialize SAM speech synthesizer with SAM voice - * @returns {object|null} SAM instance or null if unavailable - */ -function initSam() { - if (sam) return sam; - if (typeof SamJs !== 'undefined') { - // SAM preset: speed=72, pitch=64, mouth=128, throat=128 - sam = new SamJs({ speed: 72, pitch: 64, mouth: 128, throat: 128 }); - } - return sam; -} - -/** - * Speak text using SAM - * @param {string} text - Text to speak - * @returns {void} - */ -function samSpeak(text) { - const samInstance = initSam(); - if (samInstance) { - try { - samInstance.speak(text); - } catch (_err) { - // Silently fail if speech doesn't work - } - } -} - -// Expose samSpeak globally for matrix-client -window.samSpeak = samSpeak; - -// Version -const VERSION = '1.0.0'; - -// Matrix configuration -const IS_NEOCITIES_HOST = window.location.hostname.endsWith('neocities.org'); -const MATRIX_CONFIG = { - homeserver: 'https://chat.ruv.wtf', - bridgeUrl: 'https://lit.ruv.wtf/matrix-bridge.html', - useBridge: IS_NEOCITIES_HOST, - publicReadToken: 'syt_Z2VuZXJhbGNoYXQtcmVhZG9ubHk_sikLltUtfbHlztnanEVm_2icJ1o' -}; - -// Create inline input element -const inlineInput = document.createElement('input'); -inlineInput.type = 'text'; -inlineInput.className = 'terminal-inline-input'; -inlineInput.autocomplete = 'off'; -inlineInput.autocorrect = 'on'; -inlineInput.autocapitalize = 'off'; -inlineInput.spellcheck = false; - -const mentionSuggestions = document.createElement('div'); -mentionSuggestions.className = 'mention-suggestions'; -mentionSuggestions.style.display = 'none'; - -const TERMINAL_SOUND_FILES = window.location.hostname.endsWith('neocities.org') ? { - startup: 'https://lit.ruv.wtf/sounds/poweron.mp3', - commandLaunch: 'https://lit.ruv.wtf/sounds/floppyreadshort.wav', - typing: [ - 'https://lit.ruv.wtf/sounds/ui_hacking_charsingle_01.wav', - 'https://lit.ruv.wtf/sounds/ui_hacking_charsingle_02.wav', - 'https://lit.ruv.wtf/sounds/ui_hacking_charsingle_03.wav', - 'https://lit.ruv.wtf/sounds/ui_hacking_charsingle_04.wav', - 'https://lit.ruv.wtf/sounds/ui_hacking_charsingle_05.wav', - 'https://lit.ruv.wtf/sounds/ui_hacking_charsingle_06.wav' - ], - enter: [ - 'https://lit.ruv.wtf/sounds/ui_hacking_charenter_01.wav', - 'https://lit.ruv.wtf/sounds/ui_hacking_charenter_02.wav', - 'https://lit.ruv.wtf/sounds/ui_hacking_charenter_03.wav' - ], - scroll: 'https://lit.ruv.wtf/sounds/ui_hacking_charscroll.wav' -} : { - startup: 'sounds/poweron.mp3', - commandLaunch: 'sounds/floppyreadshort.wav', - typing: [ - 'sounds/ui_hacking_charsingle_01.wav', - 'sounds/ui_hacking_charsingle_02.wav', - 'sounds/ui_hacking_charsingle_03.wav', - 'sounds/ui_hacking_charsingle_04.wav', - 'sounds/ui_hacking_charsingle_05.wav', - 'sounds/ui_hacking_charsingle_06.wav' - ], - enter: [ - 'sounds/ui_hacking_charenter_01.wav', - 'sounds/ui_hacking_charenter_02.wav', - 'sounds/ui_hacking_charenter_03.wav' - ], - scroll: 'sounds/ui_hacking_charscroll.wav' -}; - -const terminalSoundState = { - startupPlayed: false, - startupUnlockBound: false, - disabled: false -}; - -/** - * Select a random item from an array. - * @param {string[]} values - Candidate values - * @returns {string} Randomly selected value - */ -function pickRandomValue(values) { - return values[Math.floor(Math.random() * values.length)]; -} - -/** - * Play a single sound file at the provided volume. - * @param {string} filePath - Relative sound file path - * @param {number} volume - Playback volume between 0 and 1 - * @returns {Promise} True when playback starts - */ -async function playSoundFile(filePath, volume) { - if (terminalSoundState.disabled) { - return false; - } - - try { - const sound = new Audio(filePath); - sound.preload = 'auto'; - sound.volume = volume; - await sound.play(); - return true; - } catch (error) { - if (!error || error.name !== 'NotAllowedError') { - terminalSoundState.disabled = true; - } - return false; - } -} - -/** - * Attempt startup sound playback and defer until user interaction if blocked. - * @returns {Promise} - */ -async function attemptStartupSoundPlayback() { - if (terminalSoundState.startupPlayed) { - return; - } - - const didPlay = await playSoundFile(TERMINAL_SOUND_FILES.startup, 0.5); - if (didPlay) { - terminalSoundState.startupPlayed = true; - terminalSoundState.startupUnlockBound = false; - return; - } - - if (terminalSoundState.startupUnlockBound) { - return; - } - - terminalSoundState.startupUnlockBound = true; - const unlockAndPlay = async () => { - if (terminalSoundState.startupPlayed) { - return; - } - - const unlocked = await playSoundFile(TERMINAL_SOUND_FILES.startup, 0.5); - if (unlocked) { - terminalSoundState.startupPlayed = true; - } - }; - - document.addEventListener('pointerdown', unlockAndPlay, { once: true }); - document.addEventListener('keydown', unlockAndPlay, { once: true }); -} - -/** - * Play keypress sound effects for terminal typing/navigation. - * @param {KeyboardEvent} event - Keyboard event - * @returns {void} - */ -function playTerminalKeySound(event) { - const key = event.key; - if (event.ctrlKey || event.altKey || event.metaKey) { - return; - } - - if (key === 'ArrowUp' || key === 'ArrowDown') { - void playSoundFile(TERMINAL_SOUND_FILES.scroll, 0.22); - return; - } - - if (key === 'Enter') { - void playSoundFile(pickRandomValue(TERMINAL_SOUND_FILES.enter), 0.26); - return; - } - - const isTypingKey = key.length === 1 || key === 'Backspace' || key === 'Delete'; - if (isTypingKey) { - void playSoundFile(pickRandomValue(TERMINAL_SOUND_FILES.typing), 0.2); - } -} - -// Initialize xterm.js terminal -const term = new Terminal({ - cursorBlink: false, - cursorStyle: 'underline', - cursorInactiveStyle: 'none', - fontFamily: '"Courier New", Courier, monospace', - fontSize: 20, - theme: { - background: '#001800', - foreground: '#00ff00', - cursor: 'transparent', - cursorAccent: 'transparent', - selection: 'rgba(0, 255, 0, 0.3)', - black: '#000000', - red: '#ff0000', - green: '#00ff00', - yellow: '#ffff00', - blue: '#0066ff', - magenta: '#ff00ff', - cyan: '#00ffff', - white: '#ffffff', - brightBlack: '#666666', - brightRed: '#ff6666', - brightGreen: '#66ff66', - brightYellow: '#ffff66', - brightBlue: '#6666ff', - brightMagenta: '#ff66ff', - brightCyan: '#66ffff', - brightWhite: '#ffffff' - }, - allowTransparency: true, - scrollback: 1000, - disableStdin: true -}); - -// Add addons -const fitAddon = new FitAddon.FitAddon(); -const webLinksAddon = new WebLinksAddon.WebLinksAddon(); - -term.loadAddon(fitAddon); -term.loadAddon(webLinksAddon); - -// Open terminal -term.open(document.getElementById('terminal')); -fitAddon.fit(); - -// Register custom link provider for clickable commands -term.registerLinkProvider({ - provideLinks: (bufferLineNumber, callback) => { - const line = term.buffer.active.getLine(bufferLineNumber - 1); - if (!line) { - callback(undefined); - return; - } - - const lineText = line.translateToString(); - const links = []; - - // Find all command names that match our known commands - const commandNames = ['help', 'about', 'clear', 'echo', 'date', 'whoami', 'history', 'color', 'banner', 'bluesky', 'chat', 'github', 'contact', 'privacy', 'numbermatch']; - const gameCommandNames = ['add', 'hint', 'new', 'quit']; - - // When in game mode, detect clickable tile numbers on board lines - if (gameMode.active && lineText.includes('│')) { - // Board line format: " A │ 5 3 7 ...│" - // Detect row letter at start - const rowMatch = lineText.match(/^\s*([A-Z])\s*│/); - if (rowMatch) { - const rowLetter = rowMatch[1]; - // Find all digits within the board area (between │ characters) - const boardStart = lineText.indexOf('│') + 1; - const boardEnd = lineText.lastIndexOf('│'); - - if (boardStart > 0 && boardEnd > boardStart) { - let col = 0; - for (let i = boardStart; i < boardEnd; i++) { - const char = lineText[i]; - // Each cell is 3 chars wide: " X " - if ((i - boardStart) % 3 === 1 && /[1-9]/.test(char)) { - col = Math.floor((i - boardStart) / 3) + 1; - const coord = `${rowLetter}${col}`; - links.push({ - range: { - start: { x: i + 1, y: bufferLineNumber }, - end: { x: i + 2, y: bufferLineNumber } - }, - text: char, - activate: () => { - handleTileClick(coord); - } - }); - } - } - } - } - } - - commandNames.forEach(cmd => { - let startIndex = 0; - while (true) { - const index = lineText.indexOf(cmd, startIndex); - if (index === -1) break; - - // Check if it's a standalone command word (surrounded by spaces, brackets, or at start/end) - const charBefore = index > 0 ? lineText[index - 1] : ' '; - const charAfter = index + cmd.length < lineText.length ? lineText[index + cmd.length] : ' '; - - const validBefore = /[\s\[\]]/.test(charBefore); - const validAfter = /[\s\[\]\-]/.test(charAfter); - - if (validBefore && validAfter) { - links.push({ - range: { - start: { x: index + 1, y: bufferLineNumber }, - end: { x: index + cmd.length + 1, y: bufferLineNumber } - }, - text: cmd, - activate: () => { - runQuickCommand(cmd); - } - }); - } - - startIndex = index + 1; - } - }); - - // When in game mode, register add/hint/new/quit as clickable words on the controls line - if (gameMode.active) { - gameCommandNames.forEach(cmd => { - let startIndex = 0; - while (true) { - const index = lineText.indexOf(cmd, startIndex); - if (index === -1) break; - const charBefore = index > 0 ? lineText[index - 1] : ' '; - const charAfter = index + cmd.length < lineText.length ? lineText[index + cmd.length] : ' '; - if (/[\s\[]/.test(charBefore) && /[\s\]]/.test(charAfter)) { - const capturedCmd = cmd; - links.push({ - range: { - start: { x: index + 1, y: bufferLineNumber }, - end: { x: index + cmd.length + 1, y: bufferLineNumber } - }, - text: cmd, - activate: () => { - if (window.runGameCommand) window.runGameCommand(capturedCmd); - } - }); - } - startIndex = index + 1; - } - }); - } - - callback(links.length > 0 ? links : undefined); - } -}); - -// Adjust font size based on screen width -function adjustFontSize() { - const width = window.innerWidth; - if (width < 350) { - term.options.fontSize = 11; - } else if (width < 480) { - term.options.fontSize = 14; - } else if (width < 768) { - term.options.fontSize = 17; - } else { - term.options.fontSize = 20; - } - fitAddon.fit(); -} - -adjustFontSize(); - -// Check viewport size for border display -function checkViewportSize() { - const width = window.innerWidth; - const height = window.innerHeight; - - if (width < 1280 || height < 720) { - document.body.classList.add('small-viewport'); - } else { - document.body.classList.remove('small-viewport'); - } -} - -checkViewportSize(); - -// Make terminal responsive -window.addEventListener('resize', () => { - adjustFontSize(); - checkViewportSize(); -}); - -// Mobile keyboard detection using Visual Viewport API -let initialViewportHeight = window.innerHeight; - -/** - * Detect if mobile keyboard is open based on viewport height reduction - */ -function checkKeyboardState() { - if (window.visualViewport) { - const viewportHeight = window.visualViewport.height; - const heightReduction = initialViewportHeight - viewportHeight; - // If viewport shrank by more than 150px, keyboard is likely open - if (heightReduction > 150) { - document.body.classList.add('keyboard-open'); - } else { - document.body.classList.remove('keyboard-open'); - } - } -} - -if (window.visualViewport) { - window.visualViewport.addEventListener('resize', () => { - checkKeyboardState(); - checkViewportSize(); - adjustFontSize(); - setTimeout(positionInlineInput, 50); - }); -} - -// Update initial height on orientation change -window.addEventListener('orientationchange', () => { - setTimeout(() => { - initialViewportHeight = window.innerHeight; - checkViewportSize(); - }, 300); -}); - -// Update system time -function updateTime() { - const now = new Date(); - const timeStr = now.toLocaleTimeString('en-US', { - hour12: false, - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); - document.getElementById('systemTime').textContent = timeStr; -} -updateTime(); -setInterval(updateTime, 1000); - -// Command history -let commandHistory = []; -let historyIndex = -1; -let currentLine = ''; -let cursorPosition = 0; - -// Matrix client - imported from matrix-client.js - -// Available commands -const commands = { - help: { - description: helpCmd.description, - execute: (args) => helpCmd.execute(term, writeClickable, VERSION, args, commandHistory) - }, - about: { - description: aboutCmd.description, - execute: (args) => aboutCmd.execute(term, writeClickable, VERSION, args, commandHistory) - }, - clear: { - description: clearCmd.description, - execute: (args) => clearCmd.execute(term, writeClickable, VERSION, args, commandHistory) - }, - echo: { - description: echoCmd.description, - execute: (args) => echoCmd.execute(term, writeClickable, VERSION, args, commandHistory) - }, - date: { - description: dateCmd.description, - execute: (args) => dateCmd.execute(term, writeClickable, VERSION, args, commandHistory) - }, - whoami: { - description: whoamiCmd.description, - execute: (args) => whoamiCmd.execute(term, writeClickable, VERSION, args, commandHistory) - }, - history: { - description: historyCmd.description, - execute: (args) => historyCmd.execute(term, writeClickable, VERSION, args, commandHistory) - }, - color: { - description: colorCmd.description, - execute: (args) => colorCmd.execute(term, writeClickable, VERSION, args, commandHistory) - }, - banner: { - description: bannerCmd.description, - execute: (args) => bannerCmd.execute(term, writeClickable, VERSION, args, commandHistory, welcomeBannerFull, welcomeBannerCompact, welcomeBannerMinimal) - }, - github: { - description: githubCmd.description, - execute: (args) => githubCmd.execute(term, writeClickable, VERSION, args, commandHistory) - }, - contact: { - description: contactCmd.description, - execute: (args) => contactCmd.execute(term, writeClickable, VERSION, args, commandHistory) - }, - privacy: { - description: privacyCmd.description, - execute: (args) => privacyCmd.execute(term, writeClickable, VERSION, args, commandHistory) - }, - donate: { - description: donateCmd.description, - execute: (args) => donateCmd.execute(term, writeClickable, VERSION, args, commandHistory) - }, - bluesky: { - description: blueskyCmd.description, - execute: async (args) => await blueskyCmd.execute(term, writeClickable, VERSION, args, commandHistory) - }, - numbermatch: { - description: numbermatchCmd.description, - execute: (args) => { - numbermatchCmd.execute(term, writeClickable, VERSION, args, commandHistory); - updateQuickCommands('game'); - } - }, - samsay: { - description: samsayCmd.description, - execute: (args) => samsayCmd.execute(term, writeClickable, VERSION, args, commandHistory) - }, - chat: { - description: 'Connect to chat room', - execute: async (args) => { - const homeserver = 'https://chat.ruv.wtf'; - const roomAlias = '#generalchat:ruv.wtf'; - - // Generate UUID - const generateUUID = () => { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { - const r = Math.random() * 16 | 0; - const v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); - }; - - // Check if user is providing credentials - let username = null; - let password = null; - let subcommand = args[0]; - let subcommandArgs = args.slice(1); - - // Fetch the latest message without logging in - if (subcommand === 'last') { - try { - const latest = await fetchPublicLastMessage(homeserver, roomAlias); - if (!latest) { - return '\r\n Could not read latest message without login (room may not be public).\r\n'; - } - - return `\r\n #generalchat latest:\r\n [${latest.time}] ${latest.sender}: ${latest.text}\r\n`; - } catch (error) { - return '\r\n Error: Failed to fetch latest message without login.\r\n'; - } - } - - // If first arg is not a known subcommand and second arg exists, treat as credentials - if (args[0] && args[1] && args[0] !== 'send' && args[0] !== 'disconnect' && args[0] !== 'last' && isNaN(parseInt(args[0]))) { - username = args[0]; - password = args[1]; - subcommand = args[2]; - subcommandArgs = args.slice(3); - } - - // Initialize Matrix session - if (!window.matrixSession) { - window.matrixSession = { - accessToken: localStorage.getItem('matrix_access_token'), - userId: localStorage.getItem('matrix_user_id'), - deviceId: localStorage.getItem('matrix_device_id'), - roomId: localStorage.getItem('matrix_room_id'), - username: localStorage.getItem('matrix_username'), - password: localStorage.getItem('matrix_password') - }; - } - - // Sync session to mxClient if exists - if (window.matrixSession.accessToken && window.matrixSession.userId) { - updateClientSession({ - accessToken: window.matrixSession.accessToken, - userId: window.matrixSession.userId - }); - } - - // Register/login user if not logged in - if (!window.matrixSession.accessToken) { - try { - // Use provided credentials or generate new ones - if (!username && window.matrixSession.username && window.matrixSession.password) { - username = window.matrixSession.username; - password = window.matrixSession.password; - } else if (!username) { - username = generateUUID(); - password = generateUUID(); - } - - term.writeln('\r\n Connecting to chat server...\r\n'); - - // Try to register - first attempt to get auth flows - let regData = await matrixApi('/register', 'POST', { - username: username, - password: password - }); - - // If we need to complete auth flow (dummy auth) - if (regData.flows && !regData.access_token) { - term.writeln(' Completing registration...\r\n'); - regData = await matrixApi('/register', 'POST', { - auth: { - type: 'm.login.dummy', - session: regData.session - }, - username: username, - password: password - }); - } - - if (regData.access_token) { - window.matrixSession.accessToken = regData.access_token; - window.matrixSession.userId = regData.user_id; - window.matrixSession.deviceId = regData.device_id; - window.matrixSession.username = username; - window.matrixSession.password = password; - - localStorage.setItem('matrix_access_token', regData.access_token); - localStorage.setItem('matrix_user_id', regData.user_id); - localStorage.setItem('matrix_device_id', regData.device_id); - localStorage.setItem('matrix_username', username); - localStorage.setItem('matrix_password', password); - - updateClientSession({ accessToken: regData.access_token, userId: regData.user_id }); - - term.writeln(` Registered as: ${regData.user_id}\r\n`); - } else if (regData.errcode === 'M_USER_IN_USE') { - // Username exists, try to login - term.writeln(' Username exists, logging in...\r\n'); - const loginData = await matrixApi('/login', 'POST', { - type: 'm.login.password', - identifier: { - type: 'm.id.user', - user: username - }, - password: password - }); - - if (loginData.access_token) { - window.matrixSession.accessToken = loginData.access_token; - window.matrixSession.userId = loginData.user_id; - window.matrixSession.deviceId = loginData.device_id; - window.matrixSession.username = username; - window.matrixSession.password = password; - - localStorage.setItem('matrix_access_token', loginData.access_token); - localStorage.setItem('matrix_user_id', loginData.user_id); - localStorage.setItem('matrix_device_id', loginData.device_id); - localStorage.setItem('matrix_username', username); - localStorage.setItem('matrix_password', password); - - updateClientSession({ accessToken: loginData.access_token, userId: loginData.user_id }); - - term.writeln(` Logged in as: ${loginData.user_id}\r\n`); - } else { - return ` Error: Failed to login - ${loginData.error || loginData.errcode || 'Unknown error'}`; - } - } else { - return ` Error: Failed to register account - ${regData.error || regData.errcode || 'Unknown error'}`; - } - } catch (error) { - console.error('Chat error:', error); - return ` Error: Could not connect to chat server - ${error.message}`; - } - } - - // Join room if not already joined - if (!window.matrixSession.roomId) { - try { - term.writeln(' Joining #generalchat...\r\n'); - - // Try joining directly with the room alias - const joinData = await matrixApi(`/join/${encodeURIComponent(roomAlias)}`, 'POST', {}); - - if (joinData && joinData.room_id) { - window.matrixSession.roomId = joinData.room_id; - localStorage.setItem('matrix_room_id', joinData.room_id); - } else if (joinData && joinData.errcode) { - // If direct join fails, try resolving alias first then joining by room ID - term.writeln(' Trying alternate join method...\r\n'); - - const resolveData = await matrixApi(`/directory/room/${encodeURIComponent(roomAlias)}`, 'GET'); - - if (!resolveData || !resolveData.room_id) { - return ` Error: Could not find room - ${resolveData?.error || resolveData?.errcode || 'Room not found'}`; - } - - const roomId = resolveData.room_id; - - // Try POST to /join/{roomId} - const joinData2 = await matrixApi(`/join/${encodeURIComponent(roomId)}`, 'POST', {}); - - if (joinData2 && joinData2.room_id) { - window.matrixSession.roomId = joinData2.room_id; - localStorage.setItem('matrix_room_id', joinData2.room_id); - } else if (joinData2 && joinData2.errcode) { - return ` Error: Failed to join room - ${joinData2.error || joinData2.errcode}`; - } else { - window.matrixSession.roomId = roomId; - localStorage.setItem('matrix_room_id', roomId); - } - } else { - // Empty response might still be success - try to get room ID from alias - const resolveData = await matrixApi(`/directory/room/${encodeURIComponent(roomAlias)}`, 'GET'); - if (resolveData && resolveData.room_id) { - window.matrixSession.roomId = resolveData.room_id; - localStorage.setItem('matrix_room_id', resolveData.room_id); - } else { - return ' Error: Failed to join room - Could not determine room ID'; - } - } - } catch (error) { - console.error('Room join error:', error); - return ` Error: Could not join chat room - ${error.message}`; - } - } - - // Handle subcommands - if (subcommand === 'disconnect') { - // Exit chat mode if active - if (chatMode.active) { - if (chatMode.pollInterval) { - clearInterval(chatMode.pollInterval); - } - chatMode.active = false; - chatMode.displayNames = {}; // Clear display name cache - term.clear(); - } - - localStorage.removeItem('matrix_access_token'); - localStorage.removeItem('matrix_user_id'); - localStorage.removeItem('matrix_device_id'); - localStorage.removeItem('matrix_room_id'); - localStorage.removeItem('matrix_username'); - localStorage.removeItem('matrix_password'); - window.matrixSession = null; - return '\r\n Disconnected from chat\r\n'; - } else { - // Enter interactive chat mode (default) - await enterChatMode(); - return null; - } - } - } -}; - -// Welcome banner - full size - NFO style -const welcomeBannerFull = [ - '', - ' ........ ', - ' ...++++++++... ......... ', - ' ...++++++++++++.................... ...+++++++.... ', - ' ...++++++----++++...+++++++++++++++....+++++++++++... ', - ' ..+++++######--++++++++++++++++++++++++++++-----+++.. ', - ' ..+++++#######-++++++++++++++++++++++++++++######++.. ', - ' ..-++++#######++++++++++++++++++++++++++++++#####+... ', - ' ..-+++++##+++++++++++++++++++++++++++++++++++##+... ', - ' ...+++++++++++++++++++++++++++++++++++++++++++... ', - ' ...++++++++++++++++----------+++++++-----++++.. ', - ' ...+++++++++++++++---.....----+####+---..---++-.. ', - ' ...+++++++++++++++++++.......-#########....+++++... ', - ' ...++++++++++++++++++++++++++############+----++++.. ', - ' ..++++++++++++++++++++++++++###############----+++.. ', - ' ..++++++++++++++++++++++++++########........----+++-.. ', - ' ..+++++++++++++++++++++++++########..........#------.. ', - ' ..+++++++++++++++++++++++++##########........##------.. ', - ' ..++++++-++++++++++++++++++############....-###------.. ', - ' ..++++++--+++++++++++++++++#############+.#####---.--.. ', - ' ..+++++++--+++++++++++++++++#########.......##----.... ', - ' ..++++++++--+-+++++++++++++++#####+##########----..... ', - ' ..+.+++++++----++--++++++++++++############-----... ', - ' .....+++++++-----+----+++++++++----------------.. ', - ' .....++++++++---------------------------------.. ', - ' ..-+++++++++------------------------------.. ', - ' ..+++++++++++---------------------------.. ', - ' ..++++++++++++------------------------.. ', - ' ..+++++++++--------------------------.. ', - ' ...+++.-----------------------------.. ', - ' ....-...---------------------------.. ', - ' .......---------------------------.. ', - ' . ....----.-------------------.. ', - ' .....-.....----------------.. ', - ' ..... .....-------------.. ', - ' LIT.RUV.WTF TERMINAL v' + VERSION + ' ', - '', - ' Type "help" for available commands.', - ' Use ↑/↓ arrows to navigate command history.', - '' -].join('\r\n'); - -// Welcome banner - compact (40 chars wide) -const welcomeBannerCompact = [ - '', - ' ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄', - ' ▄█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░█▄', - ' ██░ LIT.RUV.WTF TERMINAL ░██', - ' ██░ ═══════════════════════ ░██', - ' ██░ v' + VERSION + ' · Litruv ░██', - ' ▀█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░█▀', - ' ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀', - '', - ' Welcome! Type "help" for commands.', - '' -].join('\r\n'); - -// Welcome banner - minimal (25 chars wide) -const welcomeBannerMinimal = [ - '', - ' ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄', - ' █░ LIT.RUV.WTF ░█', - ' █░ TERMINAL v' + VERSION + ' ░█', - ' ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀', - '', - ' Type "help"', - '' -].join('\r\n'); - -// Get appropriate banner based on terminal width -function getWelcomeBanner() { - const cols = term.cols; - const width = window.innerWidth; - - // Use minimal mode for very narrow screens - if (width < 350 || cols < 30) { - return welcomeBannerMinimal; - } else if (cols >= 78) { - return welcomeBannerFull; - } else if (cols >= 40) { - return welcomeBannerCompact; - } else { - return welcomeBannerMinimal; - } -} - -// Prompt (plain version for display, we handle colors separately) -const promptText = 'user@lit.ruv.wtf $ '; -const promptColored = '\x1b[1;32muser@lit.ruv.wtf\x1b[0m $ '; - -/** - * Write the shell prompt at the current cursor position. - * @returns {void} - */ -function writePrompt() { - term.write(promptColored); -} - -/** - * Position the inline input at the cursor location - */ -function positionInlineInput() { - const terminalEl = document.getElementById('terminal'); - const xtermEl = terminalEl.querySelector('.xterm-screen'); - - if (!xtermEl) return; - - // Get terminal dimensions - const charWidth = term._core._renderService.dimensions.css.cell.width; - const charHeight = term._core._renderService.dimensions.css.cell.height; - - // Get cursor position - const cursorY = term.buffer.active.cursorY; - const cursorX = term.buffer.active.cursorX; - - // Position input at cursor (xterm-screen handles positioning) - inlineInput.style.left = (cursorX * charWidth) + 'px'; - inlineInput.style.top = (cursorY * charHeight) + 'px'; - inlineInput.style.height = charHeight + 'px'; - inlineInput.style.fontSize = term.options.fontSize + 'px'; - inlineInput.style.lineHeight = charHeight + 'px'; - - if (mentionSuggestions.style.display !== 'none') { - mentionSuggestions.style.left = inlineInput.style.left; - - // Get the actual height of the suggestions box - const suggestionsHeight = mentionSuggestions.offsetHeight || 0; - - // Position it above the cursor line - mentionSuggestions.style.top = ((cursorY * charHeight) - suggestionsHeight - 6) + 'px'; - mentionSuggestions.style.fontSize = term.options.fontSize + 'px'; - mentionSuggestions.style.lineHeight = charHeight + 'px'; - } -} - -/** - * Show the inline input and position it - */ -function showInlineInput() { - const terminalEl = document.getElementById('terminal'); - const xtermEl = terminalEl.querySelector('.xterm-screen'); - - if (xtermEl && !xtermEl.contains(inlineInput)) { - xtermEl.appendChild(inlineInput); - } - - if (xtermEl && !xtermEl.contains(mentionSuggestions)) { - xtermEl.appendChild(mentionSuggestions); - } - - inlineInput.style.display = 'block'; - positionInlineInput(); - inlineInput.focus(); -} - -/** - * Hide the inline input - */ -function hideInlineInput() { - inlineInput.style.display = 'none'; - inlineInput.value = ''; - mentionSuggestions.style.display = 'none'; -} - -/** - * Command autocomplete state - */ -const commandAutocomplete = { - matches: [], - index: -1, - tokenStart: 0, - tokenEnd: 0 -}; - -/** - * Get command matches for autocomplete - * @param {string} query - Command prefix to match - * @param {boolean} isChatMode - Whether in chat mode - * @returns {Array<{name: string, description: string}>} Matching commands - */ -function getCommandMatches(query, isChatMode) { - const lowerQuery = query.toLowerCase(); - - if (isChatMode) { - // Chat commands - const chatCommands = [ - { name: '/help', description: 'Show chat commands' }, - { name: '/nick', description: 'Change display name' }, - { name: '/samsay', description: 'Send message with SAM speech' }, - { name: '/quit', description: 'Exit chat mode' } - ]; - return chatCommands.filter(cmd => cmd.name.startsWith(lowerQuery)); - } else { - // Terminal commands - const terminalCommands = Object.keys(commands).map(name => ({ - name, - description: commands[name].description || '' - })); - return terminalCommands.filter(cmd => cmd.name.toLowerCase().startsWith(lowerQuery)); - } -} - -/** - * Check if command suggestions are visible - * @returns {boolean} True if suggestions are visible - */ -function hasVisibleCommandSuggestions() { - return mentionSuggestions.style.display !== 'none' - && commandAutocomplete.matches.length > 0; -} - -/** - * Reset command autocomplete state - * @returns {void} - */ -function resetCommandAutocomplete() { - commandAutocomplete.matches = []; - commandAutocomplete.index = -1; - commandAutocomplete.tokenStart = 0; - commandAutocomplete.tokenEnd = 0; - mentionSuggestions.style.display = 'none'; - mentionSuggestions.innerHTML = ''; -} - -/** - * Render command suggestions - * @returns {void} - */ -function renderCommandSuggestions() { - const { matches, index } = commandAutocomplete; - - if (matches.length === 0) { - mentionSuggestions.style.display = 'none'; - return; - } - - const limitedMatches = matches.slice(0, 5); - mentionSuggestions.innerHTML = limitedMatches.map((cmd, i) => { - const selected = i === index ? ' selected' : ''; - return `
    ${cmd.name} - ${cmd.description}
    `; - }).join(''); - - mentionSuggestions.style.display = 'block'; - positionInlineInput(); -} - -/** - * Refresh command suggestions based on current input - * @returns {void} - */ -function refreshCommandSuggestions() { - const value = inlineInput.value; - const cursorPos = inlineInput.selectionStart; - - // Check if we're at the start with a "/" or just typing a command - const isChatMode = chatMode.active; - let query = ''; - let tokenStart = 0; - let tokenEnd = cursorPos; - - if (isChatMode) { - // In chat mode, look for /command at start - if (value.startsWith('/')) { - const match = value.match(/^(\/\w*)/); - if (match && cursorPos <= match[1].length) { - query = match[1]; - tokenStart = 0; - tokenEnd = match[1].length; - } else { - resetCommandAutocomplete(); - return; - } - } else { - resetCommandAutocomplete(); - return; - } - } else { - // In terminal mode, look for command at start (no slash) - if (cursorPos === value.length) { - const match = value.match(/^(\w*)/); - if (match && match[1].length > 0) { - query = match[1]; - tokenStart = 0; - tokenEnd = match[1].length; - } else { - resetCommandAutocomplete(); - return; - } - } else { - resetCommandAutocomplete(); - return; - } - } - - const matches = getCommandMatches(query, isChatMode); - - if (matches.length === 0) { - resetCommandAutocomplete(); - return; - } - - commandAutocomplete.matches = matches; - commandAutocomplete.index = -1; - commandAutocomplete.tokenStart = tokenStart; - commandAutocomplete.tokenEnd = tokenEnd; - - renderCommandSuggestions(); -} - -/** - * Apply the selected command autocomplete - * @returns {boolean} True if a command was applied - */ -function applyCommandAutocomplete() { - if (!hasVisibleCommandSuggestions()) { - return false; - } - - // Cycle through commands or select first - if (commandAutocomplete.index < commandAutocomplete.matches.length - 1) { - commandAutocomplete.index++; - } else { - commandAutocomplete.index = 0; - } - - const selected = commandAutocomplete.matches[commandAutocomplete.index]; - const fullValue = inlineInput.value; - const valueAfter = fullValue.slice(commandAutocomplete.tokenEnd); - const isChatMode = chatMode.active; - - // For chat commands, include the slash; for terminal commands, don't - const commandText = selected.name; - const needsSpace = !valueAfter.startsWith(' ') && valueAfter.length > 0; - const newValue = commandText + (needsSpace ? ' ' : '') + valueAfter; - const newCursor = commandText.length + (needsSpace ? 1 : 0); - - inlineInput.value = newValue; - inlineInput.setSelectionRange(newCursor, newCursor); - - if (isChatMode) { - chatMode.inputLine = newValue; - } - - // Update token end for continued cycling - commandAutocomplete.tokenEnd = commandText.length; - - renderCommandSuggestions(); - return true; -} - -/** - * Submit current input - */ -async function submitInlineInput() { - const cmd = inlineInput.value.trim(); - const rawValue = inlineInput.value; - inlineInput.value = ''; - resetMentionAutocomplete(); - - // Handle chat mode - if (chatMode.active) { - chatMode.inputLine = rawValue; - if (cmd === '/quit' || cmd === '/exit') { - hideInlineInput(); - exitChatMode(); - return; - } - - if (cmd === '/help') { - // Move cursor up to separator, clear it and the prompt - term.write('\x1b[1A\x1b[2K\r'); - - term.writeln('\x1b[33mChat Commands:\x1b[0m'); - term.writeln(' /help - Show this help message'); - term.writeln(' /nick [name] - Change your display name'); - term.writeln(' /samsay [text] - Send message with SAM speech'); - term.writeln(' /quit - Exit chat mode'); - term.writeln('─'.repeat(term.cols || 60)); - term.write('\x1b[1;32m>\x1b[0m '); - setTimeout(() => positionInlineInput(), 10); - return; - } - - if (cmd.startsWith('/nick ')) { - const newNick = cmd.substring(6).trim(); - - // Move cursor up to separator, clear it - term.write('\x1b[1A\x1b[2K\r'); - - if (!newNick) { - term.writeln('\x1b[31mError: /nick [name]\x1b[0m'); - } else { - try { - const isTaken = await isDisplayNameTaken(newNick); - if (isTaken) { - term.writeln('\x1b[31mError: Nickname already in use\x1b[0m'); - } else { - const encodedUserId = encodeURIComponent(window.matrixSession.userId); - const putResponse = await matrixApi(`/profile/${encodedUserId}/displayname`, 'PUT', { - displayname: newNick - }); - - if (putResponse && putResponse.errcode) { - term.writeln(`\x1b[31mError: ${putResponse.error || 'Nickname rejected by server'}\x1b[0m`); - } else { - const verifyData = await matrixApi(`/profile/${encodedUserId}/displayname`, 'GET'); - const actualName = verifyData && verifyData.displayname; - - if (actualName === newNick) { - chatMode.displayNames[window.matrixSession.userId] = newNick; - term.writeln(`\x1b[32mNickname changed to: ${newNick}\x1b[0m`); - } else { - term.writeln(`\x1b[31mError: Nickname rejected by server\x1b[0m`); - } - } - } - } catch (error) { - term.writeln(`\x1b[31mError: Failed to change nickname\x1b[0m`); - } - } - term.writeln('─'.repeat(term.cols || 60)); - term.write('\x1b[1;32m>\x1b[0m '); - setTimeout(() => positionInlineInput(), 10); - return; - } - - if (cmd.startsWith('/samsay ')) { - const message = cmd.substring(8).trim(); - - if (!message) { - // Move cursor up to separator, clear it - term.write('\x1b[1A\x1b[2K\r'); - term.writeln('\x1b[31mError: /samsay [text]\x1b[0m'); - term.writeln('─'.repeat(term.cols || 60)); - term.write('\x1b[1;32m>\x1b[0m '); - setTimeout(() => positionInlineInput(), 10); - return; - } - - // Send to chat (will play when message comes back) - await sendChatMessage(cmd); - return; - } - - if (cmd && !cmd.startsWith('/')) { - // Don't write the message here - let sync handle it - await sendChatMessage(cmd); - return; - } else if (cmd.startsWith('/')) { - // Move cursor up to separator, clear it - term.write('\x1b[1A\x1b[2K\r'); - term.writeln(`\x1b[31mUnknown command: ${cmd.split(' ')[0]}. Type /help\x1b[0m`); - term.writeln('─'.repeat(term.cols || 60)); - term.write('\x1b[1;32m>\x1b[0m '); - setTimeout(() => positionInlineInput(), 10); - } else { - // Empty message, just reposition the prompt - setTimeout(() => positionInlineInput(), 10); - } - return; - } - - // Handle game mode - if (gameMode.active) { - term.write(rawValue + '\r\n'); - const continueGame = processGameInput(term, cmd); - if (!continueGame) { - updateQuickCommands('terminal'); - writePrompt(); - } - setTimeout(() => positionInlineInput(), 10); - return; - } - - // Normal command mode - term.write(rawValue + '\r\n'); - - if (cmd) { - await executeCommand(cmd); - } - - // Show prompt if not in chat mode or game mode - // Game mode handles its own prompt in renderBoard - if (!chatMode.active && !gameMode.active) { - writePrompt(); - // Delay to ensure terminal has rendered before positioning - setTimeout(() => { - positionInlineInput(); - }, 10); - } else if (gameMode.active) { - setTimeout(() => positionInlineInput(), 10); - } - - term.scrollToBottom(); -} - -/** - * Write startup chat MOTD with last message and presence - */ -async function writeStartupChatMotd() { - try { - const result = await Promise.race([ - (async () => { - const [latest, presence] = await Promise.all([ - fetchPublicLastMessage('#generalchat:ruv.wtf'), - fetchPublicPresence('@lit:ruv.wtf') - ]); - return { latest, presence }; - })(), - new Promise((resolve) => setTimeout(() => resolve({ latest: null, presence: null }), 2500)) - ]); - - if (!result.latest && !result.presence) { - return; - } - - if (result.latest) { - const sender = result.latest.sender.split(':')[0].substring(1); - const age = formatTimeAgo(result.latest.timestamp); - term.write(` \x1b[36m${sender}\x1b[0m: ${result.latest.body.substring(0, 50)}${result.latest.body.length > 50 ? '...' : ''} \x1b[90m(${age})\x1b[0m\r\n`); - } - - if (result.presence) { - const statusColor = result.presence.presence === 'online' ? '\x1b[32m' : '\x1b[90m'; - const lastActive = result.presence.lastActive > 0 ? formatTimeAgo(Date.now() - result.presence.lastActive) : null; - term.write(` \x1b[36mlitruv\x1b[0m ${statusColor}${result.presence.presence}\x1b[0m`); - if (result.presence.presence !== 'online' && lastActive) { - term.write(` \x1b[90m(${lastActive})\x1b[0m`); - } - term.writeln(''); - } - - term.write(' '); - writeClickable('[command=chat]', 'Run \x1b[32mchat\x1b[0m to join in on the conversation!'); - term.writeln('\r\n'); - } catch (error) { - // Silently fail - not critical for startup - } -} - -// Initialize terminal -async function init() { - // Initialize Matrix client with config and dependencies - initMatrixClient(MATRIX_CONFIG, { - term, - inlineInput, - mentionSuggestions, - writeClickable, - writePrompt, - showInlineInput, - positionInlineInput, - submitInlineInput, - welcomeBannerFull, - welcomeBannerCompact, - welcomeBannerMinimal - }); - - // Expose chat and game command handlers for onclick buttons - window.runChatCommand = runChatCommand; - window.runGameCommand = runGameCommand; - - // Display welcome banner with clickable commands - const cols = term.cols; - if (cols >= 78) { - term.writeln(welcomeBannerFull.split('\r\n').slice(0, -3).join('\r\n')); - writeClickable(' Type [command=help] for available commands.'); - term.writeln(' Use ↑/↓ arrows to navigate command history.'); - term.writeln(''); - } else if (cols >= 40) { - term.writeln(welcomeBannerCompact.split('\r\n').slice(0, -2).join('\r\n')); - writeClickable(' Welcome! Type [command=help] for commands.'); - term.writeln(''); - } else { - term.writeln(welcomeBannerMinimal.split('\r\n').slice(0, -2).join('\r\n')); - writeClickable(' Type [command=help]'); - term.writeln(''); - } - - await writeStartupChatMotd(); - await attemptStartupSoundPlayback(); - - writePrompt(); - - // Add inline input after a short delay to ensure terminal is rendered - setTimeout(() => { - showInlineInput(); - }, 100); - - // Handle inline input events - inlineInput.addEventListener('keydown', async (e) => { - playTerminalKeySound(e); - - // Check if we have visible suggestions (mentions OR commands) - const hasMentions = hasVisibleMentionSuggestions(); - const hasCommands = hasVisibleCommandSuggestions(); - const hasSuggestions = hasMentions || hasCommands; - - if (e.key === 'Escape' && hasSuggestions) { - e.preventDefault(); - if (hasMentions) resetMentionAutocomplete(); - if (hasCommands) resetCommandAutocomplete(); - } else if (e.key === ' ' && hasSuggestions) { - e.preventDefault(); - if (hasMentions) commitSelectedMentionSuggestion(); - if (hasCommands) { - resetCommandAutocomplete(); - inlineInput.value += ' '; - if (chatMode.active) chatMode.inputLine = inlineInput.value; - } - } else if (e.key === 'Enter' && hasSuggestions) { - e.preventDefault(); - if (hasMentions) { - commitSelectedMentionSuggestion(); - } else if (hasCommands) { - // Just accept the current command and submit - resetCommandAutocomplete(); - submitInlineInput(); - } - } else if (e.key === 'Enter') { - e.preventDefault(); - submitInlineInput(); - } else if (e.key === 'Tab') { - e.preventDefault(); - if (chatMode.active && hasMentions) { - await applyMentionAutocomplete(); - } else if (hasCommands) { - applyCommandAutocomplete(); - } else { - // Trigger command suggestions on Tab - refreshCommandSuggestions(); - if (hasVisibleCommandSuggestions()) { - applyCommandAutocomplete(); - } else if (chatMode.active) { - // Try mention autocomplete in chat - await applyMentionAutocomplete(); - } - } - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - if (hasCommands) { - // Navigate command suggestions up - if (commandAutocomplete.index > 0) { - commandAutocomplete.index--; - renderCommandSuggestions(); - } - } else if (commandHistory.length > 0) { - // Navigate command history - if (historyIndex === -1 || historyIndex >= commandHistory.length) { - historyIndex = commandHistory.length - 1; - } else if (historyIndex > 0) { - historyIndex--; - } - inlineInput.value = commandHistory[historyIndex] || ''; - } - } else if (e.key === 'ArrowDown') { - e.preventDefault(); - if (hasCommands) { - // Navigate command suggestions down - if (commandAutocomplete.index < commandAutocomplete.matches.length - 1) { - commandAutocomplete.index++; - renderCommandSuggestions(); - } - } else if (historyIndex < commandHistory.length - 1) { - historyIndex++; - inlineInput.value = commandHistory[historyIndex] || ''; - } else { - historyIndex = commandHistory.length; - inlineInput.value = ''; - } - } - }); - - inlineInput.addEventListener('input', async () => { - if (chatMode.active) { - chatMode.inputLine = inlineInput.value; - await refreshMentionSuggestionsFromInput(); - } - - // Refresh command suggestions if typing at the start - refreshCommandSuggestions(); - }); - - // Click on terminal focuses input - document.getElementById('terminal').addEventListener('click', () => { - if (inlineInput.style.display !== 'none') { - inlineInput.focus(); - } - }); - - // Reposition on resize - window.addEventListener('resize', () => { - setTimeout(positionInlineInput, 50); - }); - - // Handle scroll to keep input positioned - term.onScroll(() => { - positionInlineInput(); - }); -} - -/** - * Run a quick command from a button click - * @param {string} command - The command to execute - */ -async function runQuickCommand(command) { - if (!command) return; - - // Write command to terminal - term.writeln(`\x1b[1;32m${promptText}\x1b[0m ${command}`); - - // Execute the command - await executeCommand(command.trim()); - - // Show prompt if not in chat mode - if (!chatMode.active) { - writePrompt(); - // Delay to ensure terminal has rendered before positioning - setTimeout(() => { - positionInlineInput(); - }, 10); - } - - term.scrollToBottom(); -} - -// Make function available globally for onclick handlers -window.runQuickCommand = runQuickCommand; - -// Expose sound functions for game modules -window.terminalSounds = { - files: TERMINAL_SOUND_FILES, - play: playSoundFile, - pickRandom: pickRandomValue -}; - -/** - * Write text to terminal with clickable commands - * Parses [command=X] syntax to create clickable command links - * The link provider makes these clickable automatically - * @param {string} text - Text to write (can include [command=X] syntax) - * @param {boolean} newline - Whether to add newline after text - */ -function writeClickable(text, newline = true) { - // Parse for [command=X] syntax - const regex = /\[command=([^\]]+)\]/g; - let lastIndex = 0; - let match; - - while ((match = regex.exec(text)) !== null) { - // Write text before the match - if (match.index > lastIndex) { - term.write(text.substring(lastIndex, match.index)); - } - - // Write command with underline styling (link provider makes it clickable) - const command = match[1]; - term.write(`\x1b[4;32m${command}\x1b[0m`); - - lastIndex = regex.lastIndex; - } - - // Write remaining text - if (lastIndex < text.length) { - term.write(text.substring(lastIndex)); - } - - if (newline) { - term.write('\r\n'); - } -} - -// Execute command -async function executeCommand(input) { - if (!input) return; - - void playSoundFile(TERMINAL_SOUND_FILES.commandLaunch, 0.28); - - // Add to history - commandHistory.push(input); - historyIndex = commandHistory.length; - - // Parse command - const parts = input.split(/\s+/); - const cmd = parts[0].toLowerCase(); - const args = parts.slice(1); - - // Execute command - if (commands[cmd]) { - const output = await commands[cmd].execute(args); - if (output !== null && output !== undefined) { - term.writeln(output); - } - } else { - term.writeln(`\r\n Command not found: ${cmd}`); - writeClickable(' Type [command=help] for available commands.\r\n'); - } -} - -// Initialize when loaded - wait for boot animation first -if (window.runBootAnimation) { - runBootAnimation().then(init); -} else { - init(); -}