Something went wrong. Try again.
Tools for the Atmosphere tools.slices.network
quickslice atproto html
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042<!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Docs - tools.slices.network</title> <script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js"></script> <script src="https://cdn.jsdelivr.net/gh/bigmoves/elements@v0.2.1/dist/elements.min.js"></script> <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css" media="(prefers-color-scheme: light)"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css" media="(prefers-color-scheme: dark)"> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Lora:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet"> <style> :root { --bg: #0a0a0a; --bg-secondary: #141414; --bg-hover: #1a1a1a; --text: #e5e5e5; --text-muted: #737373; --border: #262626; --accent: #3b82f6; --accent-hover: #2563eb; --danger: #ef4444; --success: #22c55e; --font-body: "Lora", Georgia, serif; --font-ui: "Inter", -apple-system, sans-serif; --font-mono: "SF Mono", Monaco, Consolas, monospace; --font-size-ui: 0.875rem; --font-size-code: 0.875rem; --font-size-h1: 1.75rem; --font-size-h2: 1.5rem; --font-size-h3: 1.25rem; }
@media (prefers-color-scheme: light) { :root { --bg: #ffffff; --bg-secondary: #f5f5f5; --bg-hover: #e5e5e5; --text: #171717; --text-muted: #737373; --border: #e5e5e5; } }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: var(--font-body); background: var(--bg); color: var(--text); font-size: 1.125rem; line-height: 1.7; min-height: 100vh; }
.container { max-width: 650px; margin: 0 auto; padding: 2rem 1.5rem; }
header { font-family: var(--font-ui); font-size: var(--font-size-ui); display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border); }
h1, h2, h3, h4, h5, h6 { font-family: var(--font-ui); }
h1 { font-size: 1.5rem; font-weight: 600; }
.header-actions { display: flex; gap: 0.5rem; align-items: center; }
button { font-family: var(--font-ui); background: var(--accent); color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 0.875rem; font-weight: 500; }
button:hover { background: var(--accent-hover); }
button.secondary { background: var(--bg-secondary); color: var(--text); border: 1px solid var(--border); }
button.secondary:hover { background: var(--bg-hover); }
button.danger { background: var(--danger); }
.doc-list { display: flex; flex-direction: column; gap: 0.5rem; }
.doc-list { font-family: var(--font-ui); font-size: var(--font-size-ui); }
.doc-item { display: flex; justify-content: space-between; align-items: center; padding: 1rem; background: var(--bg-secondary); border-radius: 8px; cursor: pointer; transition: background 0.15s; }
.doc-item:hover { background: var(--bg-hover); }
.doc-title { font-weight: 500; }
.doc-meta { font-size: 0.875rem; color: var(--text-muted); }
.doc-slug { font-family: var(--font-mono); }
/* Facet styles */ .facet-link { color: var(--accent); text-decoration: underline; }
.facet-link:hover { color: var(--accent-hover); }
.facet-bold { font-weight: 600; }
.facet-italic { font-style: italic; }
.facet-code { font-family: var(--font-mono); background: var(--bg-hover); padding: 0.125rem 0.375rem; border-radius: 4px; font-size: 0.9em; }
.facet-codeblock { font-family: var(--font-mono); background: var(--bg-secondary); padding: 1rem; border-radius: 6px; overflow-x: auto; }
.facet-codeblock code { background: transparent; padding: 0; font-size: var(--font-size-code); line-height: 1.5; }
.facet-quote { border-left: 3px solid var(--border); padding-left: 1rem; margin: 0.5rem 0; color: var(--text-muted); font-style: italic; }
/* Block editor styles */ .block-editor { min-height: 300px; padding: 0; }
.block-editor .block { padding: 0.25rem 0; margin: 0.25rem 0; outline: none; min-height: 1.5em; }
.block-editor .block.paragraph { /* default styling */ }
.block-editor .block.heading-1 { font-family: var(--font-ui); font-size: var(--font-size-h1); font-weight: 600; }
.block-editor .block.heading-2 { font-family: var(--font-ui); font-size: var(--font-size-h2); font-weight: 600; }
.block-editor .block.heading-3 { font-family: var(--font-ui); font-size: var(--font-size-h3); font-weight: 600; }
.block-editor .block.codeBlock { position: relative; background: var(--bg-secondary); border-radius: 6px; padding: 0; }
.code-lang-select { position: absolute; top: 0.5rem; right: 0.5rem; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text-muted); font-size: 0.75rem; padding: 0.125rem 0.25rem; cursor: pointer; opacity: 0; transition: opacity 0.15s; }
.block-editor .block.codeBlock:hover .code-lang-select, .code-lang-select:focus { opacity: 1; }
.code-content { display: block; font-family: var(--font-mono); font-size: var(--font-size-code); padding: 1rem 1.25rem; white-space: pre; outline: none; min-height: 1.5em; }
.block-editor .block.quote { border-left: 3px solid var(--border); padding-left: 1rem; color: var(--text-muted); font-style: italic; }
.block-editor .block.tangledEmbed { padding: 0; min-height: auto; }
.block-editor .block.tangledEmbed[data-editing="true"] { padding: 0.5rem 1rem; background: var(--bg-secondary); border-radius: 6px; font-family: var(--font-mono); font-size: 0.875rem; }
.block-editor .block.tangledEmbed[data-editing="true"]:empty::before { content: attr(data-placeholder); color: var(--text-muted); }
.block-editor .block.tangledEmbed qs-tangled-repo-card { pointer-events: none; }
.block-editor .block.tangledEmbed:hover qs-tangled-repo-card { opacity: 0.95; }
.block-editor .block.imageEmbed { padding: 0; min-height: auto; }
.block-editor .block.imageEmbed.placeholder { border: 2px dashed var(--border); border-radius: 8px; padding: 2rem; text-align: center; color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 0.875rem; transition: border-color 0.15s, background 0.15s; }
.block-editor .block.imageEmbed.placeholder:hover, .block-editor .block.imageEmbed.placeholder.dragover { border-color: var(--accent); background: var(--bg-secondary); }
.block-editor .block.imageEmbed.placeholder .upload-icon { font-size: 2rem; margin-bottom: 0.5rem; display: block; }
.block-editor .block.imageEmbed.loading { padding: 2rem; text-align: center; color: var(--text-muted); font-family: var(--font-ui); font-size: 0.875rem; }
.block-editor .block.imageEmbed img { max-width: 100%; height: auto; border-radius: 6px; display: block; cursor: pointer; }
.block-editor .block.imageEmbed .alt-editor { margin-top: 0.5rem; }
.block-editor .block.imageEmbed .alt-editor input { width: 100%; padding: 0.375rem 0.5rem; font-family: var(--font-ui); font-size: 0.8125rem; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; color: var(--text); }
.block-editor .block.imageEmbed .alt-editor input:focus { outline: none; border-color: var(--accent); }
.block-editor .block.imageEmbed .alt-editor input::placeholder { color: var(--text-muted); }
.block-editor .block.imageEmbed .error-message { color: var(--danger); font-size: 0.875rem; margin-top: 0.5rem; }
/* Focus style for non-editable blocks (embeds) */ .block-editor .block[tabindex="0"]:focus { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 8px; }
.block-editor .block[data-placeholder]:empty:focus::before { content: attr(data-placeholder); color: var(--text-muted); pointer-events: none; }
.block-editor .block.title { font-family: var(--font-ui); font-size: 2.25rem; font-weight: 700; border: none; padding: 0; margin-bottom: 1rem; min-height: 1em; }
.block-editor .block.title:empty::before { content: attr(data-placeholder); color: var(--text-muted); }
.block-editor .block.title:focus { outline: none; }
.save-status { font-size: 0.875rem; color: var(--text-muted); transition: opacity 0.3s; }
.save-status.saved { animation: fadeOut 2s forwards; animation-delay: 1s; }
.save-status.error { color: var(--danger); }
@keyframes fadeOut { to { opacity: 0; } }
.user-menu-container { position: relative; }
.user-menu-trigger { display: flex; align-items: center; gap: 0.5rem; background: none; border: 1px solid var(--border); border-radius: 4px; padding: 0.25rem 0.5rem; cursor: pointer; color: var(--text); }
.user-menu-trigger:hover { background: var(--bg-secondary); }
.user-menu-trigger .user-avatar { margin: 0; }
.user-menu { position: absolute; top: 100%; right: 0; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); min-width: 150px; z-index: 100; margin-top: 0.25rem; font-family: var(--font-ui); font-size: var(--font-size-ui); }
.user-menu button { display: block; width: 100%; text-align: left; padding: 0.5rem 1rem; border: none; background: none; cursor: pointer; color: var(--text); }
.user-menu button:hover { background: var(--bg-secondary); }
.doc-menu-container { position: relative; margin-left: auto; }
.doc-menu-trigger { background: none; border: 1px solid var(--border); border-radius: 4px; padding: 0.125rem 0.375rem; cursor: pointer; font-size: 0.75rem; letter-spacing: 1px; color: var(--text); }
.doc-menu-trigger:hover { background: var(--bg-secondary); }
.doc-menu { position: absolute; top: 100%; right: 0; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); min-width: 150px; z-index: 100; margin-top: 0.25rem; font-family: var(--font-ui); font-size: var(--font-size-ui); }
.doc-menu button { display: block; width: 100%; text-align: left; padding: 0.5rem 1rem; border: none; background: none; cursor: pointer; color: var(--text); }
.doc-menu button:hover { background: var(--bg-secondary); }
.doc-menu button.danger { color: var(--danger); }
.slash-menu { position: absolute; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 0; min-width: 200px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 100; font-family: var(--font-ui); font-size: var(--font-size-ui); }
.slash-menu.hidden { display: none; }
.slash-menu-item { padding: 0.5rem 1rem; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; }
.slash-menu-item:hover, .slash-menu-item.selected { background: var(--bg-hover); }
.slash-menu-item .icon { width: 20px; text-align: center; color: var(--text-muted); }
/* Form styles */ .form-group { margin-bottom: 1rem; }
label { display: block; font-family: var(--font-ui); font-size: var(--font-size-ui); font-weight: 500; margin-bottom: 0.25rem; }
input, textarea { width: 100%; padding: 0.5rem; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: inherit; font-size: 1rem; }
input:focus, textarea:focus { outline: none; border-color: var(--accent); }
textarea { min-height: 300px; resize: vertical; font-family: var(--font-mono); }
.form-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
/* View mode */ .doc-view { padding: 1rem 0; }
.doc-view h2 { font-size: 2rem; margin-bottom: 0.5rem; }
.doc-view .meta { font-family: var(--font-ui); display: flex; align-items: center; gap: 0.5rem; color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border); }
.doc-view .body { }
.doc-view .body p, .doc-view .body h2, .doc-view .body h3, .doc-view .body h4, .doc-view .body blockquote, .doc-view .body pre { margin: 1rem 0; }
.doc-view .body h2 { font-family: var(--font-ui); font-size: var(--font-size-h1); font-weight: 600; }
.doc-view .body h3 { font-family: var(--font-ui); font-size: var(--font-size-h2); font-weight: 600; }
.doc-view .body h4 { font-family: var(--font-ui); font-size: var(--font-size-h3); font-weight: 600; }
.doc-view .body qs-tangled-repo-card { display: block; margin: 1rem 0; }
.doc-view .body .image-embed { margin: 1rem 0; display: table; }
.doc-view .body .image-embed .image-wrapper { position: relative; display: table; }
.doc-view .body .image-embed img { max-width: 100%; height: auto; border-radius: 6px; display: block; }
.doc-view .body .image-embed .alt-pill { position: absolute; bottom: 8px; right: 8px; background: rgba(0, 0, 0, 0.7); color: white; font-family: var(--font-ui); font-size: 0.625rem; font-weight: 600; padding: 2px 6px; border-radius: 4px; cursor: pointer; text-transform: uppercase; letter-spacing: 0.5px; transition: background 0.15s; }
.doc-view .body .image-embed .alt-pill:hover { background: rgba(0, 0, 0, 0.85); }
.doc-view .body .image-embed .alt-popover { display: none; position: absolute; bottom: 36px; right: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 0.75rem; font-family: var(--font-ui); font-size: 0.875rem; color: var(--text); max-width: 280px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 10; }
.doc-view .body .image-embed .alt-popover.visible { display: block; }
.empty-state { text-align: center; padding: 3rem; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--font-size-ui); }
.user-info { display: inline-flex; align-items: center; gap: 0.5rem; font-family: var(--font-ui); font-size: var(--font-size-ui); }
.user-avatar { width: 24px; height: 24px; border-radius: 50%; }
.user-avatar-sm { width: 18px; height: 18px; vertical-align: middle; margin-right: 0.25rem; }
/* Loading/error states */ .loading, .error { text-align: center; padding: 2rem; color: var(--text-muted); }
.error { color: var(--danger); }
.hidden { display: none; }
/* Login form */ .dialog-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.dialog { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2); max-width: 400px; width: 90%; overflow: visible; }
.dialog-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; border-bottom: 1px solid var(--border); }
.dialog-header h2 { margin: 0; font-size: 1.125rem; }
.dialog-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: var(--text-muted); padding: 0; line-height: 1; }
.dialog-close:hover { color: var(--text); }
.dialog-body { padding: 1.5rem; }
/* Info button */ .btn-info { width: 24px; height: 24px; padding: 0; border-radius: 50%; font-size: 0.875rem; font-weight: 600; background: var(--bg-hover); color: var(--text-muted); border: 1px solid var(--border); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.btn-info:hover { background: var(--border); color: var(--text); }
/* Info modal */ .dialog.dialog-wide { max-width: 480px; }
.info-section { font-family: var(--font-ui); font-size: var(--font-size-ui); margin-bottom: 1.25rem; }
.info-section:last-child { margin-bottom: 0; }
.info-section h3 { font-size: var(--font-size-ui); font-weight: 600; margin-bottom: 0.25rem; }
.info-section p { color: var(--text-muted); line-height: 1.5; }
.info-section a { color: var(--accent); }
.info-section code { font-family: var(--font-mono); background: var(--bg-hover); padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.8125rem; }
.lexicon-list { margin-top: 0.5rem; display: flex; flex-direction: column; gap: 0.375rem; }
.lexicon-list code { font-weight: 600; }
.lexicon-list .desc { color: var(--text-muted); font-size: 0.75rem; }
.login-form { font-family: var(--font-ui); font-size: var(--font-size-ui); }
.login-form h2 { margin-bottom: 1rem; }
/* qs-actor-autocomplete styling */ qs-actor-autocomplete { --qs-input-bg: var(--bg-secondary); --qs-input-border: var(--border); --qs-input-border-focus: var(--accent); --qs-input-text: var(--text); --qs-input-placeholder: var(--text-muted); --qs-dropdown-bg: var(--bg-secondary); --qs-dropdown-border: var(--border); --qs-item-hover-bg: var(--bg-hover); --qs-item-text: var(--text); --qs-item-secondary-text: var(--text-muted); display: block; margin-bottom: 1rem; } </style> </head> <body> <div class="container"> <div id="app"> <div class="loading">Loading...</div> </div> </div>
<script type="module"> import { parseFacets, renderFacetedText, facetsToDom, domToFacets, BlockTypes } from "/richtext.js";
// Parse facets from API response (may be JSON strings or objects) function parseFacetsFromApi(facets) { if (!facets || !Array.isArray(facets)) return []; return facets.map(f => { if (typeof f === 'string') { try { return JSON.parse(f); } catch { return null; } } return f; }).filter(Boolean); }
const SERVER_URL = "https://quickslice-production-cc52.up.railway.app"; const CLIENT_ID = "client_k6mx8qqN2Xj6afF2WTikxA"; const TANGLED_QUICKSLICE_INSTANCE = "https://quickslice-production-ddc3.up.railway.app";
// State const state = { view: "loading", // loading, login, list, view, edit, create documents: [], currentDoc: null, viewer: null, error: null, client: null, };
// Editor state (only used during editing) const editorState = { blocks: [], // Array of { id, type, element } during editing blockMap: new WeakMap(), // element → blockData for O(1) lookups slashMenuOpen: false, slashMenuIndex: 0, // Auto-save fields saveTimeout: null, saveStatus: "saved", // "saved" | "saving" | "error" | "dirty" saveVersion: 0, // Increments on each edit, used to detect race conditions slugOverride: null, // Manual slug override, null = auto-derive isNewDoc: false, // True if doc hasn't been saved yet lastSavedAt: null, };
// O(1) lookup for block data by element function getBlockData(element) { return editorState.blockMap.get(element); }
function getCaretOffset(element) { const selection = window.getSelection(); if (!selection.rangeCount) return 0; const range = selection.getRangeAt(0); const preRange = range.cloneRange(); preRange.selectNodeContents(element); preRange.setEnd(range.startContainer, range.startOffset); return preRange.toString().length; }
function setCaretOffset(element, offset) { const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); let currentOffset = 0; let node; while ((node = walker.nextNode())) { const len = node.textContent.length; if (currentOffset + len >= offset) { const range = document.createRange(); range.setStart(node, offset - currentOffset); range.collapse(true); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); return; } currentOffset += len; } }
const COMMON_LANGUAGES = [ 'javascript', 'typescript', 'python', 'go', 'rust', 'java', 'cpp', 'c', 'csharp', 'ruby', 'php', 'swift', 'kotlin', 'bash', 'shell', 'json', 'html', 'css', 'sql', 'graphql' ];
const CODE_HIGHLIGHT_DEBOUNCE_MS = 800;
// Clear syntax highlighting while preserving cursor position function clearCodeHighlightingPreserveCursor(codeEl) { const sel = window.getSelection(); let cursorOffset = 0;
// Save cursor offset before rebuild if (sel && sel.rangeCount) { try { const range = sel.getRangeAt(0); const preRange = document.createRange(); preRange.selectNodeContents(codeEl); preRange.setEnd(range.startContainer, range.startOffset); cursorOffset = preRange.toString().length; } catch (e) { // Selection might be outside element, ignore } }
const code = codeEl.textContent; codeEl.textContent = code; // This clears formatting but keeps text
// Restore cursor to saved offset const textNode = codeEl.firstChild; if (textNode && textNode.nodeType === Node.TEXT_NODE && sel) { try { const newRange = document.createRange(); const offset = Math.min(cursorOffset, textNode.length); newRange.setStart(textNode, offset); newRange.collapse(true); sel.removeAllRanges(); sel.addRange(newRange); } catch (e) { // Cursor restoration failed, ignore } } }
function applyCodeHighlighting(element, lang = "") { const code = element.textContent; if (!code.trim()) return;
const caretOffset = getCaretOffset(element);
try { let result; if (lang && hljs.getLanguage(lang)) { result = hljs.highlight(code, { language: lang }); } else { // Use subset of common languages for better detection result = hljs.highlightAuto(code, COMMON_LANGUAGES); } element.innerHTML = result.value; } catch (e) { // Fallback to plain text on error return; }
setCaretOffset(element, caretOffset); }
function generateBlockId() { return 'block-' + Math.random().toString(36).substr(2, 9); }
function initBlockEditor(blocks = [], title = "") { const editor = document.getElementById("block-editor"); if (!editor) return;
editorState.blocks = []; editorState.blockMap = new WeakMap(); editorState.slashMenuOpen = false; editor.innerHTML = "";
// Always add title block first addBlock("title", title, null, false);
// Add content blocks (skip if first block is a heading that matches title) let blocksToAdd = blocks; if (blocks.length > 0 && BlockTypes.isHeading(blocks[0].__typename) && blocks[0].text === title) { blocksToAdd = blocks.slice(1); }
if (blocksToAdd.length === 0) { // Add empty paragraph for new docs addBlock("paragraph", "", null, false); } else { for (const block of blocksToAdd) { const type = block.__typename || ""; const facets = parseFacetsFromApi(block.facets); if (BlockTypes.isParagraph(type)) { addBlock("paragraph", block.text, facets); } else if (BlockTypes.isHeading(type)) { addBlock("heading", block.text, facets, false, block.level); } else if (BlockTypes.isCodeBlock(type)) { addBlock("codeBlock", block.code, null, false, null, block.lang); } else if (BlockTypes.isQuote(type)) { addBlock("quote", block.text, facets); } else if (type.endsWith("TangledEmbed")) { addTangledEmbedBlock(block.handle, block.repo); } else if (BlockTypes.isImageEmbed(type)) { addImageBlock(block.image, block.alt || ""); } } }
// Focus title if empty, otherwise first content block const titleBlock = editorState.blocks[0]; if (!titleBlock.element.textContent) { titleBlock.element.focus(); } else if (editorState.blocks.length > 1) { editorState.blocks[1].element.focus(); }
// Setup paste handler for images setupEditorPasteHandler(); }
function addBlock(type, text = "", facets = null, focus = false, level = 1, lang = "") { const editor = document.getElementById("block-editor"); const id = generateBlockId(); const abortController = new AbortController(); const signal = abortController.signal;
const div = document.createElement("div"); div.id = id;
if (type === "title") { div.className = "block title"; div.dataset.type = "title"; div.contentEditable = "true"; div.dataset.placeholder = "Untitled"; if (text) { div.textContent = text; } div.addEventListener("keydown", handleBlockKeydown, { signal }); div.addEventListener("input", handleBlockInput, { signal }); div.addEventListener("paste", (e) => handleBlockPaste(e, div), { signal }); div.addEventListener("dblclick", handleBlockDblClick, { signal }); editor.appendChild(div); const blockData = { id, type: "title", element: div, abortController }; editorState.blocks.push(blockData); editorState.blockMap.set(div, blockData); if (focus) { div.focus(); } return div; }
div.className = `block ${type}${type === "heading" ? `-${level}` : ""}`; div.dataset.type = type; if (type === "heading") div.dataset.level = level; if (type === "codeBlock") div.dataset.lang = lang;
if (type === "codeBlock") { // Code blocks: select outside contentEditable, code inside const select = document.createElement("select"); select.className = "code-lang-select"; select.innerHTML = ` <option value="">auto</option> ${COMMON_LANGUAGES.map(l => `<option value="${esc(l)}"${l === lang ? ' selected' : ''}>${esc(l)}</option>`).join('')} `;
const codeEl = document.createElement("code"); codeEl.className = "code-content"; codeEl.contentEditable = "true"; codeEl.spellcheck = false;
if (text) { codeEl.textContent = text; }
div.appendChild(select); div.appendChild(codeEl);
select.addEventListener("change", (e) => { const blockData = getBlockData(div); if (blockData) { blockData.lang = e.target.value; div.dataset.lang = e.target.value; applyCodeHighlighting(codeEl, e.target.value); triggerAutoSave(); } }, { signal });
// Apply initial highlighting if there's text if (text) { applyCodeHighlighting(codeEl, lang); }
// Debounced highlighting on input - timer stored in blockData for cleanup codeEl.addEventListener("input", () => { const bd = getBlockData(div); if (bd?.highlightTimer) clearTimeout(bd.highlightTimer);
// Clear highlighting immediately when typing starts if (codeEl.querySelector("span[class*='hljs-']")) { clearCodeHighlightingPreserveCursor(codeEl); }
// Re-apply highlighting after debounce if (bd) { bd.highlightTimer = setTimeout(() => { if (document.activeElement === codeEl) { applyCodeHighlighting(codeEl, div.dataset.lang || ""); } }, CODE_HIGHLIGHT_DEBOUNCE_MS); } }, { signal });
// Store reference to code element for focus handling div._codeContent = codeEl; } else { div.contentEditable = "true"; if (text && facets) { div.innerHTML = facetsToDom(text, facets); } else if (text) { div.textContent = text; } }
// Placeholder for empty paragraphs if (type === "paragraph") { div.dataset.placeholder = "Type '/' for commands..."; }
// Event listeners - for code blocks, attach to codeEl; otherwise to div const eventTarget = (type === "codeBlock" && div._codeContent) ? div._codeContent : div; eventTarget.addEventListener("keydown", handleBlockKeydown, { signal }); eventTarget.addEventListener("input", handleBlockInput, { signal }); eventTarget.addEventListener("paste", (e) => handleBlockPaste(e, div), { signal }); div.addEventListener("dblclick", handleBlockDblClick, { signal });
editor.appendChild(div); const blockData = { id, type, element: div, level, lang, abortController }; editorState.blocks.push(blockData); editorState.blockMap.set(div, blockData);
if (focus) { div.focus(); }
return div; }
function addTangledEmbedBlock(handle, repo) { const editor = document.getElementById("block-editor"); const id = generateBlockId(); const abortController = new AbortController(); const signal = abortController.signal;
const div = document.createElement("div"); div.id = id; div.className = "block tangledEmbed"; div.dataset.type = "tangledEmbed"; div.dataset.editing = "false"; div.dataset.handle = handle; div.dataset.repo = repo; div.contentEditable = "false"; div.tabIndex = 0; // Make focusable when not editing div.innerHTML = `<qs-tangled-repo-card handle="${handle}" repo="${repo}" instance="${TANGLED_QUICKSLICE_INSTANCE}"></qs-tangled-repo-card>`;
div.addEventListener("keydown", handleBlockKeydown, { signal }); div.addEventListener("input", handleBlockInput, { signal }); div.addEventListener("paste", (e) => handleBlockPaste(e, div), { signal }); div.addEventListener("dblclick", handleBlockDblClick, { signal }); div.addEventListener("click", () => div.focus(), { signal }); // Ensure focus on click
editor.appendChild(div); const blockData = { id, type: "tangledEmbed", element: div, abortController }; editorState.blocks.push(blockData); editorState.blockMap.set(div, blockData);
return div; }
function addImageBlock(imageData = null, alt = "", focus = false) { // imageData can be: // - { url: "..." } from GraphQL query (loading existing) // - { blobRef: {...} } from upload (newly uploaded, need to construct URL) // - null for placeholder const editor = document.getElementById("block-editor"); const id = generateBlockId();
const div = document.createElement("div"); div.id = id; div.className = "block imageEmbed" + (imageData ? "" : " placeholder"); div.dataset.type = "imageEmbed"; div.contentEditable = "false"; div.tabIndex = 0;
if (imageData) { // Display mode with image let imgUrl; if (imageData.url) { // From GraphQL query - has url, ref, mimeType, size imgUrl = imageData.url; // Store as blobRef for re-saving const blobRef = { $type: "blob", ref: { $link: imageData.ref }, mimeType: imageData.mimeType, size: imageData.size, }; div.dataset.blobRef = JSON.stringify(blobRef); } else if (imageData.blobRef) { // From upload - store blobRef for saving div.dataset.blobRef = JSON.stringify(imageData.blobRef); imgUrl = imageData.dataUrl || `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${state.viewer.did}&cid=${imageData.blobRef.ref.$link}`; } div.dataset.alt = alt; div.innerHTML = ` <img src="${imgUrl}" alt="${esc(alt)}" title="${esc(alt || "Add alt text")}" /> <div class="alt-editor"> <input type="text" value="${esc(alt)}" placeholder="Alt text for accessibility" /> </div> `; setupImageBlockEvents(div); } else { // Placeholder mode div.innerHTML = ` <span class="upload-icon">🖼</span> <span>Click to upload or drop image here</span> <input type="file" accept="image/*" style="display: none;" /> `; setupImagePlaceholderEvents(div); }
editor.appendChild(div);
const blockData = { id, type: "imageEmbed", element: div }; editorState.blocks.push(blockData); editorState.blockMap.set(div, blockData);
if (focus) { div.focus(); }
return div; }
async function uploadImage(file, block) { const blockData = getBlockData(block); if (!blockData) return;
// Show loading state block.className = "block imageEmbed loading"; block.innerHTML = "Processing image...";
try { // Read and resize image const dataUrl = await readFileAsDataURL(file); const resized = await resizeImage(dataUrl, { width: 2000, height: 2000, maxSize: 900000, mode: "contain", });
block.innerHTML = "Uploading...";
// Upload via GraphQL mutation const base64Data = resized.dataUrl.split(",")[1]; const uploadResult = await gqlMutation(UPLOAD_BLOB_MUTATION, { data: base64Data, mimeType: "image/jpeg", });
if (!uploadResult.uploadBlob) { throw new Error("Upload failed"); }
const blobRef = { $type: "blob", ref: { $link: uploadResult.uploadBlob.ref }, mimeType: uploadResult.uploadBlob.mimeType, size: uploadResult.uploadBlob.size, };
// Update block to display mode block.className = "block imageEmbed"; block.dataset.blobRef = JSON.stringify(blobRef); block.dataset.alt = "";
// Use the resized dataUrl for immediate preview // The proper URL will come from GraphQL after save/reload const imgUrl = resized.dataUrl; block.innerHTML = ` <img src="${imgUrl}" alt="" title="Add alt text" /> <div class="alt-editor"> <input type="text" value="" placeholder="Alt text for accessibility" /> </div> `; setupImageBlockEvents(block); triggerAutoSave();
} catch (err) { console.error("Image upload failed:", err); block.className = "block imageEmbed placeholder"; block.innerHTML = ` <span class="upload-icon">🖼</span> <span>Click to upload or drop image here</span> <div class="error-message">Upload failed: ${esc(err.message)}</div> <input type="file" accept="image/*" style="display: none;" /> `; setupImagePlaceholderEvents(block); } }
function setupImagePlaceholderEvents(block) { const fileInput = block.querySelector('input[type="file"]');
// Click to open file picker (only if still a placeholder) block.addEventListener("click", (e) => { if (!block.classList.contains("placeholder")) return; if (e.target === fileInput) return; fileInput?.click(); });
// File selected fileInput?.addEventListener("change", (e) => { const file = e.target.files?.[0]; if (file && file.type.startsWith("image/")) { uploadImage(file, block); } });
// Drag and drop block.addEventListener("dragover", (e) => { e.preventDefault(); block.classList.add("dragover"); });
block.addEventListener("dragleave", (e) => { e.preventDefault(); block.classList.remove("dragover"); });
block.addEventListener("drop", (e) => { e.preventDefault(); block.classList.remove("dragover"); const file = e.dataTransfer?.files?.[0]; if (file && file.type.startsWith("image/")) { uploadImage(file, block); } });
// Keyboard handling block.addEventListener("keydown", (e) => { handleImageBlockKeydown(e, block); }); }
function setupImageBlockEvents(block) { const img = block.querySelector("img"); const altInput = block.querySelector(".alt-editor input");
// Click block or image to focus block (but not if clicking alt input) block.addEventListener("click", (e) => { if (e.target === altInput) return; block.focus(); });
// Alt input events altInput?.addEventListener("input", (e) => { const alt = e.target.value; block.dataset.alt = alt; if (img) { img.alt = alt; img.title = alt || "Add alt text"; } triggerAutoSave(); });
altInput?.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.preventDefault(); block.focus(); } else if (e.key === "Enter") { e.preventDefault(); // Move to next block or create new one const blockData = getBlockData(block); const blockIndex = editorState.blocks.indexOf(blockData); if (blockIndex < editorState.blocks.length - 1) { const nextBlock = editorState.blocks[blockIndex + 1]; const focusTarget = nextBlock.type === "codeBlock" ? nextBlock.element._codeContent || nextBlock.element : nextBlock.element; focusTarget.focus(); } else { const newBlock = insertBlockAfter(blockIndex, "paragraph"); newBlock.focus(); } } });
// Block keyboard handling block.addEventListener("keydown", (e) => { // Don't handle if alt input is focused if (document.activeElement === altInput) return; handleImageBlockKeydown(e, block); }); }
function handleImageBlockKeydown(e, block) { const blockData = getBlockData(block); if (!blockData) return;
const blockIndex = editorState.blocks.indexOf(blockData);
if (e.key === "Enter") { e.preventDefault(); const newBlock = insertBlockAfter(blockIndex, "paragraph"); newBlock.focus(); } else if (e.key === "Backspace" || e.key === "Delete") { e.preventDefault(); // Focus previous block before deleting if (blockIndex > 0) { const prevBlock = editorState.blocks[blockIndex - 1]; const focusTarget = prevBlock.type === "codeBlock" ? prevBlock.element._codeContent || prevBlock.element : prevBlock.element; focusTarget.focus(); } deleteBlock(blockIndex); } else if (e.key === "ArrowUp") { e.preventDefault(); if (blockIndex > 0) { const prevBlock = editorState.blocks[blockIndex - 1]; const focusTarget = prevBlock.type === "codeBlock" ? prevBlock.element._codeContent || prevBlock.element : prevBlock.element; focusTarget.focus(); } } else if (e.key === "ArrowDown") { e.preventDefault(); if (blockIndex < editorState.blocks.length - 1) { const nextBlock = editorState.blocks[blockIndex + 1]; const focusTarget = nextBlock.type === "codeBlock" ? nextBlock.element._codeContent || nextBlock.element : nextBlock.element; focusTarget.focus(); } else { // Last block - create new paragraph const newBlock = insertBlockAfter(blockIndex, "paragraph"); newBlock.focus(); } } }
function setupEditorPasteHandler() { const editor = document.getElementById("block-editor"); if (!editor) return;
editor.addEventListener("paste", async (e) => { const items = e.clipboardData?.items; if (!items) return;
for (const item of items) { if (item.type.startsWith("image/")) { e.preventDefault(); const file = item.getAsFile(); if (!file) continue;
// Get current focused block or create new one const focusedBlock = document.activeElement?.closest(".block"); const focusedBlockData = focusedBlock ? getBlockData(focusedBlock) : null;
let imageBlock; if (focusedBlockData && focusedBlockData.type === "imageEmbed" && focusedBlock.classList.contains("placeholder")) { // Paste into existing placeholder imageBlock = focusedBlock; } else { // Insert new image block after current const blockIndex = focusedBlockData ? editorState.blocks.indexOf(focusedBlockData) : editorState.blocks.length - 1;
imageBlock = insertImageBlockAfter(blockIndex); }
uploadImage(file, imageBlock); break; } } }); }
function insertImageBlockAfter(index) { const editor = document.getElementById("block-editor"); const id = generateBlockId();
const div = document.createElement("div"); div.id = id; div.className = "block imageEmbed placeholder"; div.dataset.type = "imageEmbed"; div.contentEditable = "false"; div.tabIndex = 0; div.innerHTML = ` <span class="upload-icon">🖼</span> <span>Click to upload or drop image here</span> <input type="file" accept="image/*" style="display: none;" /> `;
const afterBlock = editorState.blocks[index]?.element; if (afterBlock?.nextSibling) { editor.insertBefore(div, afterBlock.nextSibling); } else { editor.appendChild(div); }
const blockData = { id, type: "imageEmbed", element: div }; editorState.blocks.splice(index + 1, 0, blockData); editorState.blockMap.set(div, blockData);
setupImagePlaceholderEvents(div); return div; }
function handleBlockKeydown(e) { // Get the block element - if event is from code-content, find parent block let block = e.currentTarget; if (block.classList.contains("code-content")) { block = block.parentElement; } const blockData = getBlockData(block); if (!blockData) return;
// Special handling for title block if (blockData.type === "title") { if (e.key === "Enter") { e.preventDefault(); // Move focus to first content block or create one if (editorState.blocks.length > 1) { editorState.blocks[1].element.focus(); } else { insertBlockAfter(0, "paragraph"); } return; } if (e.key === "Backspace" && block.textContent === "") { e.preventDefault(); // Don't delete title block return; } if (e.key === "ArrowDown") { e.preventDefault(); if (editorState.blocks.length > 1) { editorState.blocks[1].element.focus(); } return; } // Allow normal typing, but no slash commands in title return; }
// Handle non-editable blocks (embeds) - generic keyboard navigation // Note: codeBlocks have contentEditable=false on outer, but editable inner element if (block.contentEditable === "false" && blockData.type !== "codeBlock") { const index = editorState.blocks.indexOf(blockData);
if (e.key === "Enter") { e.preventDefault(); const newBlock = insertBlockAfter(index, "paragraph"); newBlock.focus(); return; } if (e.key === "ArrowUp") { e.preventDefault(); focusPreviousBlock(blockData); return; } if (e.key === "ArrowDown") { e.preventDefault(); focusNextBlock(blockData); return; } if (e.key === "Backspace" || e.key === "Delete") { e.preventDefault(); deleteBlock(index); return; } // Letter key enters edit mode (type-specific) if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) { e.preventDefault(); enterEmbedEditMode(block, blockData); return; } return; }
// Handle slash menu navigation if open if (editorState.slashMenuOpen) { if (e.key === "ArrowDown") { e.preventDefault(); navigateSlashMenu(1); return; } else if (e.key === "ArrowUp") { e.preventDefault(); navigateSlashMenu(-1); return; } else if (e.key === "Enter") { e.preventDefault(); selectSlashMenuItem(); return; } else if (e.key === "Escape") { e.preventDefault(); closeSlashMenu(); return; } }
// Enter: create new paragraph (or newline in code blocks) if (e.key === "Enter") { // Handle Enter in tangledEmbed editing mode if (blockData.type === "tangledEmbed" && block.dataset.editing === "true") { e.preventDefault(); if (renderTangledEmbed(block)) { // Success - insert new paragraph after const newBlock = insertBlockAfter(editorState.blocks.indexOf(blockData), "paragraph"); newBlock.focus(); } return; }
if (blockData.type === "codeBlock") { if (e.metaKey || e.ctrlKey) { // Cmd/Ctrl+Enter: exit code block, create new paragraph e.preventDefault(); const index = editorState.blocks.indexOf(blockData); insertBlockAfter(index, "paragraph"); return; } // Regular Enter: insert newline manually to preserve it e.preventDefault(); const selection = window.getSelection(); const range = selection.getRangeAt(0); range.deleteContents(); const newline = document.createTextNode("\n"); range.insertNode(newline); range.setStartAfter(newline); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); // Trigger input event for highlighting on code-content const codeEl = block._codeContent || block.querySelector(".code-content"); if (codeEl) codeEl.dispatchEvent(new Event("input", { bubbles: true })); return; } if (!e.shiftKey) { e.preventDefault(); const index = editorState.blocks.indexOf(blockData); splitBlockAtCursor(block, blockData, index); } }
// Backspace at start of empty block: delete block if (e.key === "Backspace") { const selection = window.getSelection(); const isAtStart = selection.anchorOffset === 0 && selection.isCollapsed; // For code blocks, check the code-content element const textContent = blockData.type === "codeBlock" ? (block._codeContent || block.querySelector(".code-content"))?.textContent || "" : block.textContent; const isEmpty = textContent === "";
if (isEmpty && editorState.blocks.length > 1) { e.preventDefault(); const index = editorState.blocks.indexOf(blockData); deleteBlock(index); } else if (isAtStart && editorState.blocks.indexOf(blockData) > 0 && blockData.type !== "codeBlock") { // Merge with previous block if same type (not for code blocks) e.preventDefault(); const index = editorState.blocks.indexOf(blockData); mergeWithPrevious(index); } // For code blocks with content, let default backspace behavior work }
// Keyboard shortcuts for formatting if ((e.metaKey || e.ctrlKey) && blockData.type !== "codeBlock") { if (e.key === "b") { e.preventDefault(); wrapSelectionWithTag("strong"); } else if (e.key === "i") { e.preventDefault(); wrapSelectionWithTag("em"); } else if (e.key === "e") { e.preventDefault(); wrapSelectionWithTag("code"); } else if (e.key === "k") { e.preventDefault(); insertLink(); } }
// Arrow keys for block navigation if (e.key === "ArrowUp" || e.key === "ArrowDown") { const selection = window.getSelection(); if (!selection.rangeCount) return; const range = selection.getRangeAt(0);
// For code blocks, use the code-content element; otherwise use block const contentEl = blockData.type === "codeBlock" ? (block._codeContent || block.querySelector(".code-content")) : block;
if (!contentEl) return;
const textLength = contentEl.textContent.length;
// Get cursor offset from start of content element let cursorOffset = 0; const treeWalker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT); let node; while ((node = treeWalker.nextNode())) { if (node === range.startContainer) { cursorOffset += range.startOffset; break; } cursorOffset += node.textContent.length; }
const atStart = cursorOffset === 0; const atEnd = cursorOffset >= textLength;
if (e.key === "ArrowUp" && atStart) { e.preventDefault(); focusPreviousBlock(blockData); } else if (e.key === "ArrowDown" && atEnd) { e.preventDefault(); focusNextBlock(blockData); } } }
function insertBlockAfter(index, type, level = 1) { const editor = document.getElementById("block-editor"); const newBlock = document.createElement("div"); const id = generateBlockId(); const abortController = new AbortController(); const signal = abortController.signal;
newBlock.id = id; newBlock.className = `block ${type}${type === "heading" ? `-${level}` : ""}`; newBlock.dataset.type = type; newBlock.contentEditable = "true"; if (type === "paragraph") { newBlock.dataset.placeholder = "Type '/' for commands..."; } if (type === "heading") newBlock.dataset.level = level;
newBlock.addEventListener("keydown", handleBlockKeydown, { signal }); newBlock.addEventListener("input", handleBlockInput, { signal }); newBlock.addEventListener("paste", handleBlockPaste, { signal }); newBlock.addEventListener("dblclick", handleBlockDblClick, { signal });
const nextBlock = editorState.blocks[index + 1]; if (nextBlock) { editor.insertBefore(newBlock, nextBlock.element); } else { editor.appendChild(newBlock); }
const blockData = { id, type, element: newBlock, level, abortController }; editorState.blocks.splice(index + 1, 0, blockData); editorState.blockMap.set(newBlock, blockData); newBlock.focus(); triggerAutoSave(); return newBlock; }
function deleteBlock(index) { if (index < 0 || index >= editorState.blocks.length) return; if (editorState.blocks[index].type === "title") return; // Never delete title
const block = editorState.blocks[index]; // Abort all event listeners for this block if (block.abortController) { block.abortController.abort(); } editorState.blockMap.delete(block.element); block.element.remove(); editorState.blocks.splice(index, 1);
// Focus previous or next block const focusIndex = Math.max(0, index - 1); if (editorState.blocks[focusIndex]) { editorState.blocks[focusIndex].element.focus(); } triggerAutoSave(); }
function splitBlockAtCursor(block, blockData, index) { // Code blocks handle Enter differently (newlines, not split) if (blockData.type === "codeBlock") { return; }
const selection = window.getSelection(); if (!selection.rangeCount) { // No cursor, just create empty paragraph const newBlock = insertBlockAfter(index, "paragraph"); newBlock.focus(); return; }
const range = selection.getRangeAt(0);
// Create a range from cursor to end of block const rangeToEnd = document.createRange(); rangeToEnd.selectNodeContents(block); // Select entire block first rangeToEnd.setStart(range.startContainer, range.startOffset); // Then set start to cursor
// Extract content after cursor (removes it from current block) const extractedContent = rangeToEnd.extractContents();
// Create new block - paragraphs stay paragraphs, others become paragraphs const newType = "paragraph"; const newBlock = insertBlockAfter(index, newType);
// Move extracted content to new block (if any) if (extractedContent.childNodes.length > 0) { newBlock.appendChild(extractedContent); }
// Focus new block at start newBlock.focus(); const newRange = document.createRange(); if (newBlock.firstChild) { newRange.setStart(newBlock.firstChild, 0); } else { newRange.setStart(newBlock, 0); } newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange);
triggerAutoSave(); }
function mergeWithPrevious(index) { if (index === 0) return;
const current = editorState.blocks[index]; const previous = editorState.blocks[index - 1];
// Only merge text blocks if (current.type === "codeBlock" || previous.type === "codeBlock") return;
const prevLength = previous.element.textContent.length; previous.element.innerHTML += current.element.innerHTML; current.element.remove(); editorState.blocks.splice(index, 1);
// Set cursor at merge point previous.element.focus(); const range = document.createRange(); const sel = window.getSelection(); const textNode = findTextNodeAtOffset(previous.element, prevLength); if (textNode) { range.setStart(textNode.node, textNode.offset); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } }
function findTextNodeAtOffset(element, targetOffset) { let offset = 0; const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); let node; while ((node = walker.nextNode())) { const len = node.textContent.length; if (offset + len >= targetOffset) { return { node, offset: targetOffset - offset }; } offset += len; } return null; }
function focusPreviousBlock(current) { const index = editorState.blocks.indexOf(current); if (index > 0) { const prev = editorState.blocks[index - 1]; // For code blocks, focus the code-content element const focusTarget = prev.type === "codeBlock" ? prev.element._codeContent || prev.element.querySelector(".code-content") : prev.element;
if (focusTarget) { focusTarget.focus(); if (focusTarget.contentEditable === "true") { const range = document.createRange(); range.selectNodeContents(focusTarget); range.collapse(false); // End of block const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } } } }
function focusNextBlock(current) { const index = editorState.blocks.indexOf(current); if (index < editorState.blocks.length - 1) { const next = editorState.blocks[index + 1]; // For code blocks, focus the code-content element const focusTarget = next.type === "codeBlock" ? next.element._codeContent || next.element.querySelector(".code-content") : next.element;
if (focusTarget) { focusTarget.focus(); if (focusTarget.contentEditable === "true") { const range = document.createRange(); range.selectNodeContents(focusTarget); range.collapse(true); // Start of block const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } } } }
// Slash commands const SLASH_COMMANDS = [ { id: "paragraph", label: "Paragraph", icon: "P", description: "Plain text" }, { id: "heading1", label: "Heading 1", icon: "H1", description: "Large heading" }, { id: "heading2", label: "Heading 2", icon: "H2", description: "Medium heading" }, { id: "heading3", label: "Heading 3", icon: "H3", description: "Small heading" }, { id: "code", label: "Code Block", icon: "</>", description: "Code snippet" }, { id: "quote", label: "Quote", icon: '"', description: "Blockquote" }, { id: "image", label: "Image", icon: "🖼", description: "Upload an image" }, { id: "tangled", label: "Tangled Repo", icon: "🐑", description: "Embed a repo card" }, ];
function handleBlockInput(e) { // Get the block element - if event is from code-content, find parent block let block = e.currentTarget; if (block.classList.contains("code-content")) { block = block.parentElement; } const blockData = getBlockData(block);
// Trigger auto-save on any content change triggerAutoSave();
// Skip special handling for code blocks (they have their own input handler) if (blockData?.type === "codeBlock") return;
const text = block.textContent;
// Check for slash command trigger if (text === "/") { openSlashMenu(block); return; }
// Filter slash menu if open if (editorState.slashMenuOpen && text.startsWith("/")) { const filter = text.slice(1).toLowerCase(); updateSlashMenuFilter(filter); return; }
// Close slash menu if text doesn't start with / if (editorState.slashMenuOpen && !text.startsWith("/")) { closeSlashMenu(); }
// Check for triple backtick to enter code block mode if (text === "```" || text.startsWith("```\n") || text.startsWith("```")) { const blockData = getBlockData(block); if (blockData && blockData.type !== "codeBlock") { // Extract any language hint after ``` const match = text.match(/^```(\w*)/); const lang = match?.[1] || "";
// Clear the backticks BEFORE converting (so select isn't wiped) block.textContent = ""; convertBlock(blockData, "codeBlock"); blockData.lang = lang; block.dataset.lang = lang;
// Select the language in the dropdown if specified if (lang) { const select = block.querySelector(".code-lang-select"); if (select) select.value = lang; }
// Focus the code-content element const codeEl = block._codeContent || block; codeEl.focus(); return; } }
// Check for markdown auto-conversion checkMarkdownConversion(block); }
function openSlashMenu(block) { const menu = document.getElementById("slash-menu");
// Get cursor position const selection = window.getSelection(); let top, left;
if (selection.rangeCount > 0) { const range = selection.getRangeAt(0); const rect = range.getBoundingClientRect(); top = rect.bottom + window.scrollY + 5; left = rect.left + window.scrollX; } else { // Fallback to block position const blockRect = block.getBoundingClientRect(); top = blockRect.bottom + window.scrollY + 5; left = blockRect.left + window.scrollX; }
menu.style.top = `${top}px`; menu.style.left = `${left}px`;
editorState.slashMenuOpen = true; editorState.slashMenuIndex = 0; editorState.slashMenuBlock = block; editorState.slashMenuFilter = "";
renderSlashMenu(SLASH_COMMANDS); menu.classList.remove("hidden"); }
function closeSlashMenu() { const menu = document.getElementById("slash-menu"); menu.classList.add("hidden"); editorState.slashMenuOpen = false; editorState.slashMenuBlock = null; }
function renderSlashMenu(commands) { const menu = document.getElementById("slash-menu"); menu.innerHTML = commands .map( (cmd, i) => ` <div class="slash-menu-item${i === editorState.slashMenuIndex ? " selected" : ""}" data-command="${cmd.id}" onclick="DocApp.executeSlashCommand('${cmd.id}')"> <span class="icon">${cmd.icon}</span> <span>${cmd.label}</span> </div> ` ) .join(""); }
function updateSlashMenuFilter(filter) { const filtered = SLASH_COMMANDS.filter( cmd => cmd.label.toLowerCase().includes(filter) || cmd.description.toLowerCase().includes(filter) ); editorState.slashMenuIndex = 0; renderSlashMenu(filtered);
if (filtered.length === 0) { closeSlashMenu(); } }
function navigateSlashMenu(direction) { const menu = document.getElementById("slash-menu"); const items = menu.querySelectorAll(".slash-menu-item"); editorState.slashMenuIndex = Math.max( 0, Math.min(items.length - 1, editorState.slashMenuIndex + direction) ); items.forEach((item, i) => { item.classList.toggle("selected", i === editorState.slashMenuIndex); }); }
function selectSlashMenuItem() { const menu = document.getElementById("slash-menu"); const items = menu.querySelectorAll(".slash-menu-item"); const selected = items[editorState.slashMenuIndex]; if (selected) { executeSlashCommand(selected.dataset.command); } }
function executeSlashCommand(commandId) { const block = editorState.slashMenuBlock; if (!block) return;
const blockData = getBlockData(block); if (!blockData) return;
closeSlashMenu();
// Clear the slash text block.textContent = "";
// Convert block to new type if (commandId === "paragraph") { convertBlock(blockData, "paragraph"); } else if (commandId.startsWith("heading")) { const level = parseInt(commandId.replace("heading", "")); convertBlock(blockData, "heading", level); } else if (commandId === "code") { convertBlock(blockData, "codeBlock"); } else if (commandId === "quote") { convertBlock(blockData, "quote"); } else if (commandId === "tangled") { convertBlock(blockData, "tangledEmbed"); block.dataset.editing = "true"; block.dataset.placeholder = "handle/repo"; } else if (commandId === "image") { // Convert to image block const blockIndex = editorState.blocks.indexOf(blockData); const newImageBlock = document.createElement("div"); newImageBlock.id = blockData.id; newImageBlock.className = "block imageEmbed placeholder"; newImageBlock.dataset.type = "imageEmbed"; newImageBlock.contentEditable = "false"; newImageBlock.tabIndex = 0; newImageBlock.innerHTML = ` <span class="upload-icon">🖼</span> <span>Click to upload or drop image here</span> <input type="file" accept="image/*" style="display: none;" /> `;
block.replaceWith(newImageBlock); blockData.element = newImageBlock; blockData.type = "imageEmbed"; editorState.blockMap.delete(block); editorState.blockMap.set(newImageBlock, blockData);
setupImagePlaceholderEvents(newImageBlock); newImageBlock.focus();
// Auto-open file picker setTimeout(() => { newImageBlock.querySelector('input[type="file"]')?.click(); }, 100); return; }
// Focus the appropriate element (code-content for code blocks) const focusTarget = block._codeContent || block; focusTarget.focus(); }
function convertBlock(blockData, newType, level = 1) { const block = blockData.element; const content = block.innerHTML;
// Clean up code block structure if converting away from codeBlock if (blockData.type === "codeBlock" && newType !== "codeBlock") { if (blockData.highlightTimer) { clearTimeout(blockData.highlightTimer); blockData.highlightTimer = null; }
// Extract text content from code-content before removing const codeEl = block.querySelector(".code-content"); const textContent = codeEl ? codeEl.textContent : "";
// Remove code block structure const langSelect = block.querySelector(".code-lang-select"); if (langSelect) langSelect.remove(); if (codeEl) codeEl.remove();
// Set the text content directly on the block block.textContent = textContent;
// Clean up reference and reset contentEditable delete block._codeContent; block.contentEditable = "true"; }
block.className = `block ${newType}${newType === "heading" ? `-${level}` : ""}`; block.dataset.type = newType;
if (newType === "paragraph") { block.dataset.placeholder = "Type '/' for commands..."; delete block.dataset.level; } else if (newType === "heading") { block.dataset.level = level; delete block.dataset.placeholder; } else if (newType === "codeBlock") { const textContent = block.textContent; // Strip HTML block.textContent = ""; block.contentEditable = "false"; // Outer block not editable delete block.dataset.placeholder;
const signal = blockData.abortController?.signal;
// Add language selector (outside contentEditable) const select = document.createElement("select"); select.className = "code-lang-select"; select.innerHTML = ` <option value="">auto</option> ${COMMON_LANGUAGES.map(l => `<option value="${l}">${l}</option>`).join('')} `;
// Add code content element (this is contentEditable) const codeEl = document.createElement("code"); codeEl.className = "code-content"; codeEl.contentEditable = "true"; codeEl.spellcheck = false;
if (textContent) { codeEl.textContent = textContent; }
block.appendChild(select); block.appendChild(codeEl);
select.addEventListener("change", (e) => { blockData.lang = e.target.value; block.dataset.lang = e.target.value; applyCodeHighlighting(codeEl, e.target.value); triggerAutoSave(); }, { signal });
// Set up debounced highlighting (store timer for cleanup) blockData.highlightTimer = null; codeEl.addEventListener("input", () => { if (blockData.highlightTimer) clearTimeout(blockData.highlightTimer);
// Clear highlighting immediately when typing if (codeEl.querySelector("span[class*='hljs-']")) { clearCodeHighlightingPreserveCursor(codeEl); }
// Re-apply highlighting after debounce blockData.highlightTimer = setTimeout(() => { if (document.activeElement === codeEl) { applyCodeHighlighting(codeEl, block.dataset.lang || ""); } }, CODE_HIGHLIGHT_DEBOUNCE_MS); }, { signal });
// Add keydown and paste handlers to codeEl codeEl.addEventListener("keydown", handleBlockKeydown, { signal }); codeEl.addEventListener("paste", (e) => handleBlockPaste(e, block), { signal });
// Store reference to code element for focus handling block._codeContent = codeEl; } else if (newType === "quote") { delete block.dataset.placeholder; } else if (newType === "tangledEmbed") { block.dataset.editing = "true"; block.dataset.placeholder = "handle/repo"; delete block.dataset.level; }
blockData.type = newType; blockData.level = level; triggerAutoSave(); }
function renderTangledEmbed(block) { const text = block.textContent.trim(); const match = text.match(/^([^\/]+)\/(.+)$/);
if (!match) { // Invalid format, keep editing return false; }
const [, handle, repo] = match;
block.contentEditable = "false"; block.tabIndex = 0; // Make focusable when not editing block.dataset.editing = "false"; block.dataset.handle = handle; block.dataset.repo = repo; block.innerHTML = `<qs-tangled-repo-card handle="${handle}" repo="${repo}" instance="${TANGLED_QUICKSLICE_INSTANCE}"></qs-tangled-repo-card>`;
return true; }
// Generic edit mode dispatcher for embed blocks function enterEmbedEditMode(block, blockData) { switch (blockData.type) { case "tangledEmbed": editTangledEmbed(block); break; // Add cases for future embed types here: // case "imageEmbed": // editImageEmbed(block); // break; default: // No edit mode for this embed type break; } }
function editTangledEmbed(block) { const handle = block.dataset.handle || ""; const repo = block.dataset.repo || "";
block.contentEditable = "true"; block.removeAttribute("tabindex"); // Remove when editing (contentEditable handles focus) block.dataset.editing = "true"; block.textContent = handle && repo ? `${handle}/${repo}` : ""; block.focus();
// Move cursor to end const range = document.createRange(); range.selectNodeContents(block); range.collapse(false); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); }
function handleBlockDblClick(e) { const block = e.target.closest(".block"); if (!block) return;
const blockData = getBlockData(block); if (!blockData) return;
// Double-click to edit non-editable blocks (embeds) if (block.contentEditable === "false") { e.preventDefault(); enterEmbedEditMode(block, blockData); } }
// Make global for onclick // executeSlashCommand is exposed via DocApp namespace
// Markdown auto-conversion function checkMarkdownConversion(block) { const blockData = getBlockData(block); if (!blockData || blockData.type === "codeBlock") return;
const selection = window.getSelection(); if (!selection.isCollapsed) return;
const range = selection.getRangeAt(0); const textNode = range.startContainer; if (textNode.nodeType !== Node.TEXT_NODE) return;
const text = textNode.textContent; const cursor = range.startOffset;
// Check for inline code: `text` if (text[cursor - 1] === "`") { const before = text.slice(0, cursor - 1); const openTick = before.lastIndexOf("`"); if (openTick !== -1 && openTick < cursor - 2) { const codeText = before.slice(openTick + 1); // Replace with <code> tag const beforeCode = text.slice(0, openTick); const afterCode = text.slice(cursor);
const parent = textNode.parentNode; const frag = document.createDocumentFragment();
if (beforeCode) frag.appendChild(document.createTextNode(beforeCode));
const codeEl = document.createElement("code"); codeEl.textContent = codeText; frag.appendChild(codeEl);
if (afterCode) frag.appendChild(document.createTextNode(afterCode));
parent.replaceChild(frag, textNode);
// Position cursor after code const newRange = document.createRange(); newRange.setStartAfter(codeEl); newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange); return; } }
// Check for bold: **text** if (text.slice(cursor - 2, cursor) === "**") { const before = text.slice(0, cursor - 2); const openBold = before.lastIndexOf("**"); if (openBold !== -1 && openBold < cursor - 4) { const boldText = before.slice(openBold + 2); applyInlineConversion(textNode, openBold, cursor, boldText, "strong"); return; } }
// Check for italic: *text* (but not **) if (text[cursor - 1] === "*" && text[cursor - 2] !== "*") { const before = text.slice(0, cursor - 1); // Find opening * that's not part of ** let openItalic = -1; for (let i = before.length - 1; i >= 0; i--) { if (before[i] === "*" && before[i - 1] !== "*" && before[i + 1] !== "*") { openItalic = i; break; } } if (openItalic !== -1 && openItalic < cursor - 2) { const italicText = before.slice(openItalic + 1); applyInlineConversion(textNode, openItalic, cursor, italicText, "em"); return; } }
// Check for URL: auto-link when space typed after URL const lastChar = text[cursor - 1]; const isSpace = lastChar === " " || lastChar === "\n" || lastChar?.charCodeAt(0) === 32 || lastChar?.charCodeAt(0) === 160; if (isSpace) { const before = text.slice(0, cursor - 1); // Find URL ending at cursor-1 const urlMatch = before.match(/(https?:\/\/[^\s<>\[\]()]+)$/); if (urlMatch) { const url = urlMatch[1]; const urlStart = before.length - url.length;
// Check if already inside an <a> or <code> tag let node = textNode.parentNode; while (node && node !== block) { if (node.tagName === "A" || node.tagName === "CODE") return; // Already linked or in code node = node.parentNode; }
const beforeUrl = text.slice(0, urlStart); const afterUrl = text.slice(cursor - 1); // includes the space
const parent = textNode.parentNode; const frag = document.createDocumentFragment();
if (beforeUrl) frag.appendChild(document.createTextNode(beforeUrl));
const link = document.createElement("a"); link.href = url; link.className = "facet-link"; link.textContent = url; frag.appendChild(link);
if (afterUrl) frag.appendChild(document.createTextNode(afterUrl));
parent.replaceChild(frag, textNode);
// Position cursor after the space (after the link) const afterNode = link.nextSibling; if (afterNode) { const newRange = document.createRange(); newRange.setStart(afterNode, 1); // After the space newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange); } return; } } }
function applyInlineConversion(textNode, start, end, content, tagName) { const text = textNode.textContent; const beforeText = text.slice(0, start); const afterText = text.slice(end);
const parent = textNode.parentNode; const frag = document.createDocumentFragment();
if (beforeText) frag.appendChild(document.createTextNode(beforeText));
const el = document.createElement(tagName); el.textContent = content; frag.appendChild(el);
if (afterText) frag.appendChild(document.createTextNode(afterText));
parent.replaceChild(frag, textNode);
// Position cursor after element const selection = window.getSelection(); const newRange = document.createRange(); newRange.setStartAfter(el); newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange); }
// Formatting helpers for toolbar/keyboard shortcuts function wrapSelectionWithTag(tagName) { const selection = window.getSelection(); if (!selection.rangeCount || selection.isCollapsed) return false;
const range = selection.getRangeAt(0); const selectedText = range.toString(); if (!selectedText) return false;
// Check if we're in a block editor element const block = range.commonAncestorContainer.closest?.(".block") || range.commonAncestorContainer.parentElement?.closest?.(".block"); if (!block) return false;
// Check if already wrapped in this tag const parentEl = range.commonAncestorContainer.parentElement; if (parentEl && parentEl.tagName.toLowerCase() === tagName.toLowerCase()) { // Unwrap: replace tag with its text content const text = document.createTextNode(parentEl.textContent); parentEl.parentNode.replaceChild(text, parentEl); const newRange = document.createRange(); newRange.selectNodeContents(text); selection.removeAllRanges(); selection.addRange(newRange); return true; }
// Wrap selection in tag const wrapper = document.createElement(tagName); try { range.surroundContents(wrapper); } catch (e) { // surroundContents fails if selection crosses element boundaries // Fall back to extracting and wrapping const fragment = range.extractContents(); wrapper.appendChild(fragment); range.insertNode(wrapper); }
// Select the wrapped content const newRange = document.createRange(); newRange.selectNodeContents(wrapper); selection.removeAllRanges(); selection.addRange(newRange); return true; }
function insertLink() { const selection = window.getSelection(); if (!selection.rangeCount) return;
const range = selection.getRangeAt(0); const selectedText = range.toString();
// Check if we're in a block editor element const block = range.commonAncestorContainer.closest?.(".block") || range.commonAncestorContainer.parentElement?.closest?.(".block"); if (!block) return;
const url = prompt("Enter URL:", "https://"); if (!url || url === "https://") return;
const link = document.createElement("a"); link.href = url; link.className = "facet-link";
if (selectedText) { link.textContent = selectedText; range.deleteContents(); } else { link.textContent = url; }
range.insertNode(link);
// Position cursor after link const newRange = document.createRange(); newRange.setStartAfter(link); newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange); }
function handleBlockPaste(event, block) { event.preventDefault();
const text = event.clipboardData.getData("text/plain"); if (!text) return;
const blockData = getBlockData(block); if (!blockData) return;
// For code blocks, just insert as-is (preserve newlines) if (blockData.type === "codeBlock") { const codeEl = block._codeContent || block.querySelector(".code-content"); const selection = window.getSelection(); if (!selection.rangeCount) return; const range = selection.getRangeAt(0); range.deleteContents(); const textNode = document.createTextNode(text); range.insertNode(textNode); const newRange = document.createRange(); newRange.setStartAfter(textNode); newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange); // Dispatch on code-content to trigger highlighting if (codeEl) codeEl.dispatchEvent(new Event("input", { bubbles: true })); return; }
// Split by double newlines (paragraph breaks) const paragraphs = text.split(/\n\n+/).map(p => p.trim()).filter(p => p);
if (paragraphs.length === 0) return;
const selection = window.getSelection(); if (!selection.rangeCount) return; const range = selection.getRangeAt(0); range.deleteContents();
// Insert first paragraph into current block const firstText = paragraphs[0].replace(/\n/g, ' '); // Single newlines become spaces const textNode = document.createTextNode(firstText); range.insertNode(textNode);
// Create new blocks for remaining paragraphs let currentIndex = editorState.blocks.indexOf(blockData); for (let i = 1; i < paragraphs.length; i++) { const paraText = paragraphs[i].replace(/\n/g, ' '); currentIndex++; const newBlock = insertBlockAfter(currentIndex - 1, "paragraph"); if (newBlock) { newBlock.textContent = paraText; } }
// Position cursor at end of last block const lastBlock = editorState.blocks[currentIndex]; if (lastBlock) { lastBlock.element.focus(); const newRange = document.createRange(); newRange.selectNodeContents(lastBlock.element); newRange.collapse(false); selection.removeAllRanges(); selection.addRange(newRange); }
triggerAutoSave(); }
// GraphQL helpers async function gqlQuery(query, variables = {}) { const res = await fetch(`${SERVER_URL}/graphql`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }), }); const json = await res.json(); if (json.errors) { throw new Error(json.errors[0].message); } return json.data; }
async function gqlMutation(query, variables = {}) { if (!state.client) { throw new Error("Not authenticated"); } return await state.client.mutate(query, variables); }
// OAuth helpers async function initClient() { if (!state.client) { state.client = await QuicksliceClient.createQuicksliceClient({ server: SERVER_URL, clientId: CLIENT_ID, scope: "atproto repo:network.slices.tools.document blob:image/*", }); } return state.client; }
function isCallbackRoute() { return window.location.pathname === "/docs/callback"; }
async function handleOAuthCallback() { if (!isCallbackRoute()) return false;
const params = new URLSearchParams(window.location.search);
// Start OAuth flow from callback route if (params.get("start") === "1") { window.history.replaceState({}, document.title, "/docs/callback"); await startOAuthFromCallback(); return true; }
// Handle OAuth error const urlError = params.get("error"); if (urlError) { state.error = params.get("error_description") || urlError; window.location.href = "/docs"; return true; }
// No code - redirect to docs if (!params.has("code")) { window.location.href = "/docs"; return true; }
// Exchange code for tokens try { await initClient(); await state.client.handleRedirectCallback(); window.history.replaceState({}, document.title, "/docs/callback"); const returnUrl = sessionStorage.getItem("oauth_return_url") || "/docs"; sessionStorage.removeItem("oauth_return_url"); window.location.href = returnUrl; return true; } catch (err) { console.error("OAuth callback error:", err); state.error = err.message || "Authentication failed"; window.location.href = "/docs"; return true; } }
async function handleLogin(event) { event.preventDefault(); const handle = document.getElementById("handle").value.trim();
if (!handle) { state.error = "Please enter your handle"; showLoginDialog(); return; }
sessionStorage.setItem("oauth_return_url", window.location.href); sessionStorage.setItem("oauth_handle", handle); window.location.href = "/docs/callback?start=1"; }
async function startOAuthFromCallback() { const handle = sessionStorage.getItem("oauth_handle"); sessionStorage.removeItem("oauth_handle");
if (!handle) { window.location.href = "/docs"; return; }
try { await initClient(); await state.client.loginWithRedirect({ handle }); } catch (err) { console.error("Login error:", err); state.error = "Login failed: " + err.message; window.location.href = "/docs"; } }
function handleLogout() { if (state.client) { state.client.logout(); } window.location.reload(); }
function toggleUserMenu() { const menu = document.getElementById("user-menu"); if (menu) menu.classList.toggle("hidden"); }
// Escape HTML function esc(str) { if (!str) return ""; return str .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """); }
// Slugify text for URL-friendly slugs function slugify(text) { return text .toLowerCase() .trim() .replace(/[^\w\s-]/g, '') // Remove special chars .replace(/\s+/g, '-') // Spaces to hyphens .replace(/-+/g, '-') // Collapse multiple hyphens .substring(0, 100); // Limit length }
// Image resize utilities function readFileAsDataURL(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); }); }
function getDataUrlSize(dataUrl) { const base64 = dataUrl.split(",")[1]; return Math.ceil((base64.length * 3) / 4); }
function createResizedImage(dataUrl, options) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { let scale; if (options.mode === "cover") { scale = Math.max(options.width / img.width, options.height / img.height); } else if (options.mode === "contain") { scale = Math.min(options.width / img.width, options.height / img.height); } else { scale = 1; }
// Don't upscale scale = Math.min(scale, 1);
const w = Math.round(img.width * scale); const h = Math.round(img.height * scale);
const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h;
const ctx = canvas.getContext("2d"); if (!ctx) return reject(new Error("Failed to get canvas context"));
ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, w, h); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high"; ctx.drawImage(img, 0, 0, w, h);
resolve({ dataUrl: canvas.toDataURL("image/jpeg", options.quality), width: w, height: h, }); }; img.onerror = (e) => reject(e); img.src = dataUrl; }); }
async function resizeImage(dataUrl, opts) { // Binary search for optimal quality let bestResult = null; let minQuality = 0; let maxQuality = 101;
while (maxQuality - minQuality > 1) { const quality = Math.round((minQuality + maxQuality) / 2); const result = await createResizedImage(dataUrl, { width: opts.width, height: opts.height, quality: quality / 100, mode: opts.mode, });
const size = getDataUrlSize(result.dataUrl);
if (size < opts.maxSize) { minQuality = quality; bestResult = result; } else { maxQuality = quality; } }
if (!bestResult) { throw new Error("Failed to compress image within size limit"); }
return bestResult; }
function dataURLtoBlob(dataUrl) { const arr = dataUrl.split(","); const mime = arr[0].match(/:(.*?);/)[1]; const bstr = atob(arr[1]); let n = bstr.length; const u8arr = new Uint8Array(n); while (n--) { u8arr[n] = bstr.charCodeAt(n); } return new Blob([u8arr], { type: mime }); }
// Queries const DOCUMENTS_QUERY = ` query GetDocuments($handle: String, $first: Int!, $after: String) { networkSlicesToolsDocument( where: { actorHandle: { eq: $handle } } sortBy: [{ field: createdAt, direction: DESC }] first: $first after: $after ) { edges { node { uri actorHandle title slug blocks { __typename ... on NetworkSlicesToolsDocumentParagraph { text facets } ... on NetworkSlicesToolsDocumentHeading { level text facets } ... on NetworkSlicesToolsDocumentCodeBlock { code lang } ... on NetworkSlicesToolsDocumentQuote { text facets } ... on NetworkSlicesToolsDocumentTangledEmbed { handle repo } ... on NetworkSlicesToolsDocumentImageEmbed { image { url ref mimeType size } alt } } createdAt updatedAt appBskyActorProfileByDid { displayName avatar { url(preset: "avatar") } } } } pageInfo { hasNextPage endCursor } } } `;
const ALL_DOCUMENTS_QUERY = ` query GetAllDocuments($first: Int!, $after: String) { networkSlicesToolsDocument( sortBy: [{ field: createdAt, direction: DESC }] first: $first after: $after ) { edges { node { uri actorHandle title slug blocks { __typename ... on NetworkSlicesToolsDocumentParagraph { text facets } ... on NetworkSlicesToolsDocumentHeading { level text facets } ... on NetworkSlicesToolsDocumentCodeBlock { code lang } ... on NetworkSlicesToolsDocumentQuote { text facets } ... on NetworkSlicesToolsDocumentTangledEmbed { handle repo } ... on NetworkSlicesToolsDocumentImageEmbed { image { url ref mimeType size } alt } } createdAt updatedAt appBskyActorProfileByDid { displayName avatar { url(preset: "avatar") } } } } pageInfo { hasNextPage endCursor } } } `;
const UPLOAD_BLOB_MUTATION = ` mutation UploadBlob($data: String!, $mimeType: String!) { uploadBlob(data: $data, mimeType: $mimeType) { ref mimeType size } } `;
const CREATE_DOCUMENT_MUTATION = ` mutation CreateDocument($input: NetworkSlicesToolsDocumentInput!) { createNetworkSlicesToolsDocument(input: $input) { uri } } `;
const UPDATE_DOCUMENT_MUTATION = ` mutation UpdateDocument($rkey: String!, $input: NetworkSlicesToolsDocumentInput!) { updateNetworkSlicesToolsDocument(rkey: $rkey, input: $input) { uri } } `;
const DELETE_DOCUMENT_MUTATION = ` mutation DeleteDocument($rkey: String!) { deleteNetworkSlicesToolsDocument(rkey: $rkey) { uri } } `;
const DOCUMENT_BY_SLUG_QUERY = ` query GetDocumentBySlug($handle: String!, $slug: String!) { networkSlicesToolsDocument( where: { actorHandle: { eq: $handle } slug: { eq: $slug } } first: 1 ) { edges { node { uri actorHandle title slug blocks { __typename ... on NetworkSlicesToolsDocumentParagraph { text facets } ... on NetworkSlicesToolsDocumentHeading { level text facets } ... on NetworkSlicesToolsDocumentCodeBlock { code lang } ... on NetworkSlicesToolsDocumentQuote { text facets } ... on NetworkSlicesToolsDocumentTangledEmbed { handle repo } ... on NetworkSlicesToolsDocumentImageEmbed { image { url ref mimeType size } alt } } createdAt updatedAt appBskyActorProfileByDid { displayName avatar { url(preset: "avatar") } } } } } } `;
// URL routing helpers function parseDocUrl() { const path = window.location.pathname; // Match /docs/{handle}/{slug} or /docs.html with defined path after const match = path.match(/\/docs(?:\.html)?\/([^\/]+)\/([^\/]+)\/?$/); if (match) { return { handle: match[1], slug: match[2] }; } return null; }
function updateUrl(handle, slug) { const basePath = window.location.pathname.includes('.html') ? '/docs.html' : '/docs'; const newPath = handle && slug ? `${basePath}/${handle}/${slug}` : basePath; window.history.pushState({}, '', newPath); }
// Helper to extract rkey from URI function extractRkey(uri) { const parts = uri.split("/"); return parts[parts.length - 1]; }
// Data fetching async function loadDocuments(handle = null) { state.loading = true; state.error = null; render();
try { const query = handle ? DOCUMENTS_QUERY : ALL_DOCUMENTS_QUERY; const variables = handle ? { handle, first: 50 } : { first: 50 }; const data = await gqlQuery(query, variables); const key = "networkSlicesToolsDocument"; state.documents = data[key]?.edges?.map((e) => e.node) || []; } catch (err) { state.error = err.message; }
state.loading = false; render(); }
async function createDocument(title, slug, blocks) { const input = { title, slug, blocks: blocks.map(block => serializeBlock(block)), createdAt: new Date().toISOString(), };
await gqlMutation(CREATE_DOCUMENT_MUTATION, { input });
// Fetch the newly created document to get its URI const data = await gqlQuery(DOCUMENT_BY_SLUG_QUERY, { handle: state.viewer.handle, slug, }); const newDoc = data.networkSlicesToolsDocument?.edges?.[0]?.node; if (newDoc) { state.currentDoc = newDoc; updateUrl(newDoc.actorHandle, newDoc.slug); } }
function serializeBlock(block) { const base = { $type: `network.slices.tools.document#${block.type}` };
if (block.type === "paragraph") { return { ...base, text: block.text, facets: block.facets || [] }; } else if (block.type === "heading") { return { ...base, level: block.level, text: block.text, facets: block.facets || [] }; } else if (block.type === "codeBlock") { return { ...base, code: block.code, lang: block.lang || undefined }; } else if (block.type === "quote") { return { ...base, text: block.text, facets: block.facets || [] }; } else if (block.type === "tangledEmbed") { return { ...base, handle: block.handle, repo: block.repo }; } else if (block.type === "imageEmbed") { return { ...base, image: block.image, alt: block.alt || "" }; } return base; }
async function updateDocument(uri, title, slug, blocks) { const input = { title, slug, blocks: blocks.map(block => serializeBlock(block)), createdAt: state.currentDoc.createdAt, updatedAt: new Date().toISOString(), };
const oldSlug = state.currentDoc.slug;
await gqlMutation(UPDATE_DOCUMENT_MUTATION, { rkey: extractRkey(uri), input, });
// Update local state (don't reload from server to avoid re-rendering editor) state.currentDoc.title = title; state.currentDoc.slug = slug;
// Update URL if slug changed if (oldSlug !== slug) { updateUrl(state.currentDoc.actorHandle, slug); } }
async function deleteDocument(uri) { if (!confirm("Delete this document?")) return;
await gqlMutation(DELETE_DOCUMENT_MUTATION, { rkey: extractRkey(uri), }); await loadDocuments(state.viewer?.handle); state.view = "list"; state.currentDoc = null; render(); }
function formatTime(iso) { if (!iso) return ""; const d = new Date(iso); const now = new Date(); const diff = now - d;
if (diff < 60000) return "just now"; if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; if (diff < 604800000) return `${Math.floor(diff / 86400000)}d ago`;
return d.toLocaleDateString(); }
function renderSaveStatus() { const status = editorState.saveStatus; const classes = `save-status ${status}`;
let text = ""; switch (status) { case "saving": text = "Saving..."; break; case "saved": text = "Saved"; break; case "error": text = "Error saving"; break; case "dirty": text = ""; break; // Don't show anything when dirty }
return `<div class="${classes}">${text}</div>`; }
function updateSaveStatusUI() { const el = document.querySelector(".save-status"); if (el) { el.className = `save-status ${editorState.saveStatus}`; switch (editorState.saveStatus) { case "saving": el.textContent = "Saving..."; break; case "saved": el.textContent = "Saved"; break; case "error": el.textContent = "Error saving"; break; default: el.textContent = ""; } } }
function triggerAutoSave() { // Clear any pending save if (editorState.saveTimeout) { clearTimeout(editorState.saveTimeout); }
editorState.saveVersion++; editorState.saveStatus = "dirty"; updateSaveStatusUI();
// Debounce: save 1.5 seconds after last change editorState.saveTimeout = setTimeout(() => { performAutoSave(); }, 1500); }
async function performAutoSave() { if (!state.currentDoc && !editorState.isNewDoc) return; if (!state.viewer) return;
const titleBlock = editorState.blocks.find(b => b.type === "title"); const title = titleBlock?.element.textContent.trim() || "Untitled";
// Don't save if title is empty/untitled and no content if (title === "Untitled" || title === "") { const hasContent = editorState.blocks.some(b => { if (b.type === "title") return false; if (b.type === "imageEmbed") return !!b.element.dataset.blobRef; if (b.type === "tangledEmbed") return !!(b.element.dataset.handle && b.element.dataset.repo); return b.element.textContent.trim(); }); if (!hasContent) return; }
const slug = editorState.slugOverride || slugify(title) || "untitled"; const contentBlocks = extractBlocksFromEditor();
// Capture version at start of save const versionAtStart = editorState.saveVersion;
editorState.saveStatus = "saving"; updateSaveStatusUI();
try { if (editorState.isNewDoc) { await createDocument(title, slug, contentBlocks); editorState.isNewDoc = false; } else { await updateDocument(state.currentDoc.uri, title, slug, contentBlocks); }
// Only mark as saved if no new edits occurred during save if (editorState.saveVersion === versionAtStart) { editorState.saveStatus = "saved"; editorState.lastSavedAt = Date.now(); } // If version changed, status is already "dirty" from triggerAutoSave } catch (err) { console.error("Auto-save failed:", err); editorState.saveStatus = "error"; }
updateSaveStatusUI(); }
function renderDocMenu() { const isOwner = state.viewer?.handle === state.currentDoc?.actorHandle; if (!isOwner && !editorState.isNewDoc) return "";
return ` <div class="doc-menu-container"> <button class="doc-menu-trigger" onclick="DocApp.toggleDocMenu()">•••</button> <div id="doc-menu" class="doc-menu hidden"> <button onclick="DocApp.showSlugEditor()">Edit slug</button> <button onclick="DocApp.deleteCurrentDocument()" class="danger">Delete</button> </div> </div> `; }
function toggleDocMenu() { const menu = document.getElementById("doc-menu"); menu.classList.toggle("hidden"); }
function showSlugEditor() { toggleDocMenu(); const currentSlug = editorState.slugOverride || slugify(getTitleText()) || "untitled"; const newSlug = prompt("Edit slug:", currentSlug); if (newSlug !== null && newSlug !== currentSlug) { editorState.slugOverride = slugify(newSlug); triggerAutoSave(); } }
function getTitleText() { const titleBlock = editorState.blocks.find(b => b.type === "title"); return titleBlock?.element.textContent.trim() || ""; }
function deleteCurrentDocument() { if (state.currentDoc) { deleteDocument(state.currentDoc.uri); } }
function render() { const app = document.getElementById("app");
if (state.view === "loading") { app.innerHTML = '<div class="loading">Loading...</div>'; return; }
if (state.view === "login") { state.view = "list"; render(); showLoginDialog(); return; }
let html = "";
// Header (only shown when logged in or viewing public docs) html += ` <header> <h1>Docs</h1> <button class="btn-info" onclick="DocApp.openInfoModal()" title="How it works">?</button> <div class="header-actions"> ${ state.viewer ? ` <div class="user-menu-container"> <button class="user-menu-trigger" onclick="DocApp.toggleUserMenu()"> ${state.viewer.appBskyActorProfileByDid?.avatar ? `<img src="${esc(state.viewer.appBskyActorProfileByDid.avatar.url)}" class="user-avatar" />` : ""} @${esc(state.viewer.handle)} </button> <div id="user-menu" class="user-menu hidden"> <button onclick="DocApp.showNewDocument(); DocApp.toggleUserMenu();">+ New Document</button> <button onclick="DocApp.handleLogout()">Logout</button> </div> </div> ` : ` <button onclick="DocApp.showLogin()">Login</button> ` } </div> </header> `;
// Error display if (state.error) { html += `<div class="error">Error: ${esc(state.error)}</div>`; }
// Content based on view if (state.view === "list") { html += renderList(); } else if (state.view === "doc") { html += renderDocument(); }
app.innerHTML = html;
// Initialize block editor if in doc mode and can edit if (state.view === "doc") { const isOwner = state.viewer?.handle === state.currentDoc?.actorHandle; const canEdit = isOwner || editorState.isNewDoc; if (canEdit) { setTimeout(() => { initBlockEditor(state.currentDoc?.blocks || [], state.currentDoc?.title || ""); }, 0); } else { // Apply syntax highlighting to read-only code blocks document.querySelectorAll('pre code').forEach((el) => { hljs.highlightElement(el); }); } } }
function showLoginDialog() { // Remove existing dialog if any const existing = document.getElementById("login-dialog"); if (existing) existing.remove();
const dialog = document.createElement("div"); dialog.id = "login-dialog"; dialog.className = "dialog-overlay"; dialog.innerHTML = ` <div class="dialog"> <div class="dialog-header"> <h2>Login to Docs</h2> <button class="dialog-close" onclick="DocApp.closeLoginDialog()">×</button> </div> <div class="dialog-body"> <div class="login-form"> ${state.error ? `<div class="error" style="margin-bottom: 1rem;">${esc(state.error)}</div>` : ""} <form onsubmit="DocApp.handleLogin(event)"> <div class="form-group"> <label for="handle-autocomplete">AT Protocol Handle</label> <qs-actor-autocomplete id="handle-autocomplete" placeholder="you.bsky.social"></qs-actor-autocomplete> <input type="hidden" id="handle" /> </div> <button type="submit">Continue</button> </form> </div> </div> </div> `;
// Close on overlay click dialog.addEventListener("click", (e) => { if (e.target === dialog) closeLoginDialog(); });
document.body.appendChild(dialog);
// Set up actor autocomplete event listener const autocomplete = document.getElementById("handle-autocomplete"); if (autocomplete) { autocomplete.addEventListener("qs-select", (e) => { document.getElementById("handle").value = e.detail.actor.handle; }); } }
function closeLoginDialog() { const dialog = document.getElementById("login-dialog"); if (dialog) dialog.remove(); state.error = null; }
function showLogin() { state.error = null; showLoginDialog(); }
function renderList() { if (state.documents.length === 0) { return ` <div class="empty-state"> <p>No documents yet.</p> ${state.viewer ? "<p>Create your first document!</p>" : "<p>Login to create documents.</p>"} </div> `; }
let html = '<div class="doc-list">';
for (const doc of state.documents) { const profile = doc.appBskyActorProfileByDid; html += ` <div class="doc-item" onclick="DocApp.showDocumentByUri('${esc(doc.uri)}')"> <div> <div class="doc-title">${esc(doc.title)}</div> <div class="doc-meta"> ${profile?.avatar ? `<img src="${esc(profile.avatar.url)}" class="user-avatar user-avatar-sm" />` : ""} @${esc(doc.actorHandle)} · ${formatTime(doc.updatedAt || doc.createdAt)} </div> </div> </div> `; }
html += "</div>"; return html; }
function renderDocument() { const doc = state.currentDoc; const isOwner = state.viewer?.handle === doc?.actorHandle; const canEdit = isOwner || editorState.isNewDoc;
if (canEdit) { // Editable view with block editor return ` <div class="editor-container"> <div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;"> <button class="secondary" onclick="DocApp.showList()">← Back</button> ${renderDocMenu()} </div> <div id="block-editor" class="block-editor"></div> <div id="slash-menu" class="slash-menu hidden"></div> </div> `; } else { // Read-only view if (!doc) return '<div class="error">Document not found</div>'; const profile = doc.appBskyActorProfileByDid;
return ` <div class="doc-view"> <div style="margin-bottom: 1rem;"> <button class="secondary" onclick="DocApp.showList()">← Back</button> </div> <h2>${esc(doc.title)}</h2> <div class="meta"> <span class="user-info"> ${profile?.avatar ? `<img src="${esc(profile.avatar.url)}" class="user-avatar" />` : ""} @${esc(doc.actorHandle)} </span> · ${formatTime(doc.updatedAt || doc.createdAt)} · <span class="doc-slug">/${esc(doc.slug)}</span> </div> <div class="body">${renderBlocks(doc.blocks || [])}</div> </div> `; } }
function renderBlocks(blocks) { return blocks.map(block => { const type = block.__typename || ""; const facets = parseFacetsFromApi(block.facets);
if (BlockTypes.isParagraph(type)) { return `<p>${renderFacetedText(block.text, facets, { escapeHtml: esc })}</p>`; } else if (BlockTypes.isHeading(type)) { const tag = `h${block.level + 1}`; // h2, h3, h4 (h1 is doc title) return `<${tag}>${renderFacetedText(block.text, facets, { escapeHtml: esc })}</${tag}>`; } else if (BlockTypes.isCodeBlock(type)) { const langClass = block.lang ? `language-${esc(block.lang)}` : ""; return `<pre class="facet-codeblock"><code class="${langClass}">${esc(block.code)}</code></pre>`; } else if (BlockTypes.isQuote(type)) { return `<blockquote class="facet-quote">${renderFacetedText(block.text, facets, { escapeHtml: esc })}</blockquote>`; } else if (type.endsWith("TangledEmbed")) { return `<qs-tangled-repo-card handle="${esc(block.handle)}" repo="${esc(block.repo)}" instance="${TANGLED_QUICKSLICE_INSTANCE}"></qs-tangled-repo-card>`; } else if (BlockTypes.isImageEmbed(type)) { const imgUrl = block.image?.url || ""; const alt = block.alt || ""; const altId = `alt-${Math.random().toString(36).slice(2, 9)}`; return `<figure class="image-embed"> <div class="image-wrapper"> <img src="${esc(imgUrl)}" alt="${esc(alt)}" /> ${alt ? `<span class="alt-pill" onclick="document.getElementById('${altId}').classList.toggle('visible')">ALT</span> <div id="${altId}" class="alt-popover">${esc(alt)}</div>` : ""} </div> </figure>`; } return ""; }).join(""); }
// Navigation async function showList() { state.view = "list"; state.currentDoc = null; editorState.isNewDoc = false; editorState.slugOverride = null; updateUrl(null, null); // Reload documents to get fresh data await loadDocuments(state.viewer?.handle); }
function showDocument(doc) { state.view = "doc"; state.currentDoc = doc; editorState.isNewDoc = false; editorState.slugOverride = null; editorState.saveStatus = "saved"; updateUrl(doc.actorHandle, doc.slug); render(); }
function showNewDocument() { if (!state.viewer) { alert("Please login first"); return; } state.view = "doc"; state.currentDoc = null; editorState.isNewDoc = true; editorState.slugOverride = null; editorState.saveStatus = "dirty"; updateUrl(state.viewer.handle, "new"); render(); }
function showDocumentByUri(uri) { const doc = state.documents.find(d => d.uri === uri); if (doc) showDocument(doc); }
function extractBlocksFromEditor() { const blocks = [];
for (const editorBlock of editorState.blocks) { const { type, element, level, lang } = editorBlock;
// Skip title block - it's handled separately if (type === "title") { continue; }
if (type === "codeBlock") { // Code blocks - get text from code-content element const codeEl = element.querySelector(".code-content"); const code = codeEl ? codeEl.textContent : ""; blocks.push({ type: "codeBlock", code, lang: lang || undefined }); } else if (type === "tangledEmbed") { // Tangled embed - get handle and repo from dataset const handle = element.dataset.handle || ""; const repo = element.dataset.repo || ""; if (handle && repo) { blocks.push({ type: "tangledEmbed", handle, repo }); } } else if (type === "imageEmbed") { // Image embed - get blob ref and alt from dataset const blobRefStr = element.dataset.blobRef; const alt = element.dataset.alt || ""; if (blobRefStr) { try { const image = JSON.parse(blobRefStr); blocks.push({ type: "imageEmbed", image, alt }); } catch (e) { console.error("Failed to parse image blob ref:", e); } } } else { // Paragraph, heading, quote - use domToFacets const { text, facets } = domToFacets(element); if (type === "heading") { blocks.push({ type: "heading", level: level || 1, text, facets }); } else if (type === "quote") { blocks.push({ type: "quote", text, facets }); } else { blocks.push({ type: "paragraph", text, facets }); } } }
return blocks; }
// Initialize async function init() { // Warn before leaving with unsaved changes window.addEventListener("beforeunload", (e) => { if (editorState.saveStatus === "dirty" || editorState.saveStatus === "saving") { e.preventDefault(); e.returnValue = ""; } });
// Close dropdown menus on click outside document.addEventListener("click", (e) => { const docMenu = document.getElementById("doc-menu"); const docTrigger = e.target.closest(".doc-menu-trigger"); if (docMenu && !docMenu.classList.contains("hidden") && !docTrigger) { docMenu.classList.add("hidden"); }
const userMenu = document.getElementById("user-menu"); const userTrigger = e.target.closest(".user-menu-trigger"); if (userMenu && !userMenu.classList.contains("hidden") && !userTrigger) { userMenu.classList.add("hidden"); } });
try { // Handle OAuth callback if on callback route const isCallback = await handleOAuthCallback(); if (isCallback) return;
// Try to initialize client and check auth await initClient(); const isLoggedIn = state.client && (await state.client.isAuthenticated());
if (isLoggedIn) { // Fetch viewer info (must use client.query for auth) try { const data = await state.client.query(` query { viewer { did handle appBskyActorProfileByDid { displayName avatar { url(preset: "avatar") } } } } `); state.viewer = data?.viewer; } catch (err) { console.error("Failed to fetch viewer:", err); } }
// Check if URL points to a specific document const docUrl = parseDocUrl(); if (docUrl) { // Handle /docs/{handle}/new URL for new documents if (docUrl.slug === "new") { if (state.viewer && state.viewer.handle === docUrl.handle) { showNewDocument(); return; } else { // Not owner or not logged in, redirect to list await loadDocuments(); state.view = "list"; render(); return; } }
try { const data = await gqlQuery(DOCUMENT_BY_SLUG_QUERY, { handle: docUrl.handle, slug: docUrl.slug, }); const doc = data.networkSlicesToolsDocument?.edges?.[0]?.node; if (doc) { state.currentDoc = doc; state.documents = [doc]; state.view = "doc"; render(); return; } } catch (err) { console.error("Failed to load document:", err); } }
// Load documents and show list await loadDocuments(); state.view = "list"; render(); } catch (err) { console.error("Init error:", err); state.error = err.message; state.view = "list"; render(); } }
function openInfoModal() { const existing = document.getElementById("info-dialog"); if (existing) existing.remove();
const dialog = document.createElement("div"); dialog.id = "info-dialog"; dialog.className = "dialog-overlay"; dialog.innerHTML = ` <div class="dialog dialog-wide"> <div class="dialog-header"> <h2>How Docs Works</h2> <button class="dialog-close" onclick="DocApp.closeInfoModal()">×</button> </div> <div class="dialog-body"> <div class="info-section"> <h3>Creating Documents</h3> <p>Anyone with an <a href="https://internethandle.org/" target="_blank">internet handle</a> can create documents. Each document gets a shareable URL based on your handle and a custom slug.</p> </div>
<div class="info-section"> <h3>Rich Content</h3> <p>Documents support headings, paragraphs, code blocks with syntax highlighting, blockquotes, and embeds (limited). Use <strong>bold</strong>, <em>italic</em>, <code>code</code>, and links.</p> </div>
<div class="info-section"> <h3>Ownership</h3> <p>Only you can edit or delete your own documents. Your content lives in your personal data repository, giving you full ownership.</p> </div>
<div class="info-section"> <h3>Built on ATmosphere</h3> <p>Docs is built on the <a href="https://atproto.com/" target="_blank">AT Protocol</a>. Your documents are stored in your personal data repository, giving you ownership of your data.</p> </div>
<div class="info-section"> <h3>Lexicons</h3> <p>Docs uses the following <a href="https://tangled.sh/slices.network/tools/tree/main/lexicons" target="_blank">lexicon schemas</a>:</p> <div class="lexicon-list"> <div> <code>network.slices.tools.document</code> <div class="desc">Documents with title, slug, and rich content blocks</div> </div> <div> <code>network.slices.tools.richtext.facet</code> <div class="desc">Inline formatting: bold, italic, links, and code</div> </div> </div> </div> </div> </div> `; dialog.addEventListener("click", (e) => { if (e.target === dialog) closeInfoModal(); }); document.body.appendChild(dialog); }
function closeInfoModal() { const dialog = document.getElementById("info-dialog"); if (dialog) dialog.remove(); }
// Make functions global for onclick handlers // Expose functions via single namespace to reduce global pollution window.DocApp = { handleLogin, handleLogout, showLogin, showList, showDocument, showDocumentByUri, showNewDocument, deleteDocument, toggleDocMenu, toggleUserMenu, showSlugEditor, deleteCurrentDocument, executeSlashCommand, closeLoginDialog, openInfoModal, closeInfoModal, };
// Handle browser back/forward window.addEventListener("popstate", async () => { const docUrl = parseDocUrl(); if (docUrl) { // Handle /new URL if (docUrl.slug === "new" && state.viewer?.handle === docUrl.handle) { showNewDocument(); return; }
const data = await gqlQuery(DOCUMENT_BY_SLUG_QUERY, { handle: docUrl.handle, slug: docUrl.slug, }); const doc = data.networkSlicesToolsDocument?.edges?.[0]?.node; if (doc) { showDocument(doc); return; } } showList(); });
init(); </script> </body></html>