メインコンテンツへスキップ
見出し画像
Photo byresponse

【UI改善】【片手操作】ざっくりスマホで編集するためのテキストエディタを自作した話【iPhoneショートカット】【Scriptable】

    先日ご紹介したこちらの記事の謎のテキストエディタ。
    通勤中に使っていたのですが、やはり使いづらく(笑)
    またもUIを大幅に改善することに・・・

    【難点】
    ✅それぞれの機能は便利だと思うのですが、1モードにつき1機能しか画面に表示されないため、機能を組み合わせての操作ができない。
     例:「文字サイズ切り替えて」見やすくしつつ「矢印ボタン」でカーソル移動させて「文字選択」してコピーして「貼り付ける」
    (この1連の操作でモード切替4回も行うのはストレス(泣))

    ✅タップ切り替えモードが6種類あるため、目当てのモード出すまで時間がかかる。

    【画面イメージ】

    画像
    結局全ボタン表示・・・
    画像
    貼付ボタンを長押しすると
    ボタン群が隠れて再表示ボタンのみに

    【変更点】
    ✅切り替えモードのボタンは廃止して全ての機能ボタンを1画面に表示。
    ✅貼付ボタンを長押しするとボタン群が非表示になり、代わりに「再表示」用のボタンのみの表示に切り替え。
    ✅選択していない状態で左ボタンを押すと全選択してくれる機能を改良して、全選択した内容をクリップボードに格納する仕様とした(この操作が多かったため)
    ✅異常終了緩和のため、終了処理を強化(最後の通知が消えるまで待つと次回起動時に安定する想定)

    今回のレシピ本体
    (
    レシピとは別にScriptableというアプリとコードの設定が必要となります。設定は前回の記事をご参照ください)


    【Scriptableのコード】

    // ========================================
    const FILE_NAME = "editor_memo9999996.txt"
    
    if(args.queryParameters.exit === "1"){
      Pasteboard.copyString("NO_SAVE")
      await new Promise(r=>Timer.schedule(0.4,false,r))
      Safari.open("shortcuts://run-shortcut?name=エディタ")
      Script.complete()
      return
    }
    
    const fm = FileManager.local()
    const path = fm.joinPath(fm.documentsDirectory(), FILE_NAME)
    
    let initialText = ""
    if (fm.fileExists(path)) initialText = fm.readString(path)
    
    initialText = initialText.replace(/\\/g,"\\\\").replace(/`/g,"\\`")
    
    // ========================================
    const html = `
    <!DOCTYPE html>
    <html>
    <head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <style>
    body { margin:0; font-family: monospace; }
    
    #warning{
      height:16px;
      background:#fdd;
      color:#900;
      font-size:8px;
      display:flex;
      align-items:center;
      padding-left:6px;
    }
    
    #editorArea{ height:calc(100vh - 16px); }
    #scroll{ height:100%; overflow:auto; }
    
    #editor{
      width:100%;
      min-height:100%;
      font-size:10px;
      padding:4px;
      box-sizing:border-box;
    }
    
    #overlay{
      position:fixed;
      right:6px;
      top:6px;
      display:grid;
      grid-template-columns:1fr 1fr;
      gap:4px;
      width:48vw;
      max-width:220px;
      z-index:1000;
    }
    
    #showBtn{
      display:none;
      grid-column:2;
      width:100%;
    }
    
    .hiddenAll > *:not(#showBtn){
      display:none !important;
    }
    
    .hiddenAll #showBtn{
      display:block !important;
    }
    /* ===== ここまで ===== */
    
    #overlay input,
    #overlay button{
      width:100%;
      box-sizing:border-box;
      font-size:10px;
    }
    
    :root{ --btnH:3.2vh; }
    
    #overlay input{ height:var(--btnH); }
    
    #overlay button{
      height:calc(var(--btnH) * 1.1);
      touch-action:manipulation;
      -webkit-user-select:none;
    }
    
    #overlay button:focus{ outline:none; }
    </style>
    </head>
    
    <body>
    
    <div id="warning">
    ⚠ closeは使わないでください
    </div>
    
    <div id="editorArea">
      <div id="scroll">
        <textarea id="editor"></textarea>
      </div>
    </div>
    
    <div id="overlay">
    
      <!-- 再表示 -->
      <button id="showBtn">再表示</button>
    
      <!-- 既存UIそのまま -->
      <input id="searchBox" placeholder="変更前">
      <input id="replaceBox" placeholder="変更後">
    
      <button id="exitBtn">終了</button>
      <button id="pasteBtn">貼付</button>
    
      <button id="loadBtn">呼出</button>
      <button id="saveBtn">保存</button>
    
      <button id="searchBtn">検索</button>
      <button id="replaceBtn">置換</button>
    
      <button id="fontMinusBtn">文字-</button>
      <button id="fontPlusBtn">文字+</button>
    
      <button id="selMinusBtn">選択-</button>
      <button id="selPlusBtn">選択+</button>
    
      <button id="leftBtn">←</button>
      <button id="rightBtn">→</button>
    
    </div>
    
    <script>
    
    let editor = document.getElementById("editor")
    const overlay = document.getElementById("overlay")
    
    let hiddenMode = false
    let fontSize = 10
    
    // ===== UI切替(フォーカス触らない)=====
    function toggleUI(show){
      hiddenMode = !show
      if(hiddenMode){
        overlay.classList.add("hiddenAll")
      }else{
        overlay.classList.remove("hiddenAll")
      }
    }
    
    // 再表示
    showBtn.onclick = (e)=>{
      e.preventDefault()
      e.stopPropagation()
      toggleUI(true)
    }
    
    // 貼付長押し
    let pasteTimer=null
    
    pasteBtn.addEventListener("touchstart",(e)=>{
      e.preventDefault()
      e.stopPropagation()
    
      pasteTimer=setTimeout(()=>{
        toggleUI(false)
      },400)
    })
    
    pasteBtn.addEventListener("touchend",(e)=>{
      e.preventDefault()
      e.stopPropagation()
    
      if(pasteTimer){
        clearTimeout(pasteTimer)
        window.result="GET_CLIP"
      }
    })
    
    // ===== 元機能 =====
    function moveCursor(d){
      let p = editor.selectionStart + d
      p = Math.max(0, Math.min(editor.value.length, p))
      editor.selectionStart = p
      editor.selectionEnd = p
    }
    
    function findNext(){
      const w = searchBox.value
      if(!w) return
      let i = editor.value.indexOf(w, editor.selectionEnd)
      if(i === -1) i = editor.value.indexOf(w, 0)
      if(i !== -1){
        editor.selectionStart = i
        editor.selectionEnd = i + w.length
      }
    }
    
    function replaceAll(){
      const w = searchBox.value
      const r = replaceBox.value
      if(!w) return
      editor.value = editor.value.split(w).join(r)
    }
    
    function setHold(btn, fn){
      let timer=null
      let interval=null
      let hold=false
    
      btn.addEventListener("touchstart",(e)=>{
        e.preventDefault()
        e.stopPropagation()
    
        hold=false
        timer=setTimeout(()=>{
          hold=true
          fn()
          interval=setInterval(fn,80)
        },350)
      })
    
      btn.addEventListener("touchend",(e)=>{
        e.preventDefault()
        e.stopPropagation()
    
        if(timer) clearTimeout(timer)
        if(interval) clearInterval(interval)
    
        if(!hold) fn()
      })
    }
    
    // ボタン
    exitBtn.onclick = ()=>window.result="EXIT"
    loadBtn.onclick = ()=>window.result="LOAD"
    
    saveBtn.onclick = ()=>{
      const t = editor.value
      window.result = JSON.stringify({type:"save", text:t})
    }
    
    searchBtn.onclick = findNext
    replaceBtn.onclick = replaceAll
    
    setHold(leftBtn, ()=>moveCursor(-1))
    setHold(rightBtn, ()=>moveCursor(1))
    
    setHold(fontMinusBtn, ()=>{
      fontSize--
      editor.style.fontSize = fontSize+"px"
    })
    
    setHold(fontPlusBtn, ()=>{
      fontSize++
      editor.style.fontSize = fontSize+"px"
    })
    
    setHold(selPlusBtn, ()=>{
      editor.selectionEnd = Math.min(editor.value.length, editor.selectionEnd + 1)
    })
    
    setHold(selMinusBtn, ()=>{
      if(editor.selectionStart === editor.selectionEnd){
        if(editor.value.length < 20000){
          editor.selectionStart = 0
          editor.selectionEnd = editor.value.length
    
          window.result = JSON.stringify({
            type:"copy_all",
            text: editor.value
          })
        }
      }else{
        editor.selectionEnd = Math.max(editor.selectionStart, editor.selectionEnd - 1)
      }
    })
    
    function applyPasteText(t){
      const s = editor.selectionStart
      const e = editor.selectionEnd
      editor.value = editor.value.slice(0,s)+t+editor.value.slice(e)
      const p = s+t.length
      editor.selectionStart = p
      editor.selectionEnd = p
    }
    
    // 初期
    setTimeout(()=>{
      editor.value = String(window.initialText || "")
    
      // ★追加:カーソル先頭固定
      editor.selectionStart = 0
      editor.selectionEnd = 0
    },0)
    
    </script>
    </body>
    </html>
    `
    
    // ========================================
    const wv = new WebView()
    await wv.loadHTML(html)
    wv.present(true)
    
    await wv.evaluateJavaScript("window.initialText="+JSON.stringify(initialText), false)
    await wv.evaluateJavaScript("editor.value=window.initialText", false)
    await wv.evaluateJavaScript("editor.selectionStart=0;editor.selectionEnd=0", false)
    
    // ===== ループ =====
    while(true){
      await new Promise(r=>Timer.schedule(0.3,false,r))
    
      let result=null
      try{
        result = await wv.evaluateJavaScript("window.result", false)
      }catch(e){
        break
      }
    
      if(!result) continue
    
      await wv.evaluateJavaScript("window.result=null", false)
    
      let p=null
      try{ p = JSON.parse(result) }catch(e){}
    
      if(p && p.type==="save"){
        try{ fm.writeString(path, p.text) }catch(e){}
        continue
      }
    
      if(p && p.type==="copy_all"){
        Pasteboard.copyString(p.text || "")
        continue
      }
    
      if(result==="GET_CLIP"){
        const clip = Pasteboard.pasteString() || ""
        await wv.evaluateJavaScript("applyPasteText("+JSON.stringify(clip)+")", false)
        continue
      }
    
      if(result==="LOAD"){
        const text = fm.fileExists(path) ? fm.readString(path) : ""
        await wv.evaluateJavaScript("editor.value="+JSON.stringify(text), false)
        continue
      }
    
      if(result==="EXIT"){
        Pasteboard.copyString("NO_SAVE")
        try{wv.dismiss()}catch(e){}
        await new Promise(r=>Timer.schedule(0.5,false,r))
        Safari.open("scriptable:///run?scriptName=edit&exit=1")
        Script.complete()
        return
      }
    }

    今回はこのアプリが必要となります。
    設定方法は前回記事をご参照ください。


    まとめ

    一連の入力する操作や編集がだいぶスムーズになったような気がしますね。個人的には使ってて楽しくなってきました
    (^^)


    iPhoneショートカットアプリ関連


    この記事が参加している募集

     
     
    本当のお仕事は底辺の非正規労働者。東京都在住。現実逃避のためchatGPTと一緒に無駄に真面目な開発ごっこをしています。効率化とタスク管理を好む一方で非効率でも自動で動くカッコよさげな仕組みも好き。実はITスキル低め。趣味はAbemaの報道リアリティショー『アベマプライム』視聴。

    あなたへのおすすめ