Silverlightで作る付箋紙アプリ ラスト

長々と続いたけどこれでラスト。付箋紙の編集機能を実装する。

まずは付箋紙の情報を変更するためのサーバーサイドの処理。
付箋紙の情報を変更するためのURLは「~/Item/Set.aspx」にする。付箋紙のidは必須!!

では、ItemControllerクラスにSetというメソッドを実装する。

Controllers/ItemController.cs

using System;
using System.IO;
using System.Web;
using System.Web.Mvc;
using System.Xml.Linq;
using System.Linq;
using System.Text;
using System.Diagnostics;

namespace Fusen.Web.Controllers {
 /// <summary>
 /// 付箋紙アイテムに対するアクセスを提供するコントローラクラス
 /// </summary>
 public class ItemController : Controller {
     /// <summary>
     /// 付箋紙の情報を保存するファイル
     /// </summary>
     private const string DATA_FILE_PATH = "~/App_Data/items.xml";

     public ItemController() {
     }

     /// <summary>
     /// 付箋紙情報の一覧を取得します。
     /// </summary>
     public void List() {
         var fileName = Request.MapPath(DATA_FILE_PATH);

         using(var sr = new StreamReader(fileName, Encoding.UTF8)) {
             Response.ContentEncoding = Encoding.UTF8;
             Response.ContentType = "text/xml";
             Response.Output.Write(sr.ReadToEnd());
             Response.End();
         }
     }

     /// <summary>
     /// 指定した情報で付箋紙の情報を追加します。
     /// </summary>
     /// <param name="left">左端の位置</param>
     /// <param name="top">上端の位置</param>
     /// <param name="width">横幅</param>
     /// <param name="height">縦高さ</param>
     /// <param name="color">色の名前</param>
     public void New(double? left, double? top, double? width, double? height, string color) {
         var fileName = Request.MapPath(DATA_FILE_PATH);
            
         var id = DateTime.Now.ToString("yyyyMMdd-HHmmssffff");
         var xml = XDocument.Load(fileName);
         xml.Root.Add(new XElement("item", new[] {
            new XElement("id", id),
            new XElement("left", left), new XElement("top", top),
            new XElement("width", width), new XElement("height", height),
            new XElement("color", color),
            new XElement("comment")
        }));
         xml.Save(fileName);

         Response.ContentEncoding = Encoding.UTF8;
         Response.ContentType = "text/plain";
         Response.Output.Write(id);
         Response.End();
     }

     /// <summary>
     /// 指定したidの付箋紙を削除します。
     /// </summary>
     /// <param name="id">付箋紙のid</param>
     public void Delete(string id) {
         var fileName = Request.MapPath(DATA_FILE_PATH);

         var xml = XDocument.Load(fileName);
         var result = from item in xml.Descendants("item")
                      where (string)item.Element("id") == id
                      select item;

         foreach(var item in result.ToList()) item.Remove();

         xml.Save(fileName);
     }

     /// <summary>
     /// 指定したidの付箋紙の情報を設定します。
     /// </summary>
     /// <param name="id">付箋紙のid</param>
     /// <param name="left">左端の位置</param>
     /// <param name="top">上端の位置</param>
     /// <param name="width">横幅</param>
     /// <param name="height">縦高さ</param>
     /// <param name="color">色名</param>
     /// <param name="comment">コメント</param>
     public void Set(string id, double? left, double? top, double? width, double? height, string color, string comment) {
         if(string.IsNullOrEmpty(id)) {
             // 400 Bad Request
             throw new HttpException(400, "idを指定してください。");
         }
         var fileName = Request.MapPath(DATA_FILE_PATH);

         var xml = XDocument.Load(fileName);
         var result = from item in xml.Descendants("item")
                      where (string)item.Element("id") == id
                      select item;

         foreach(var item in result) {
             item.SetElementValueIfNotNull("left", left)
                 .SetElementValueIfNotNull("top", top)
                 .SetElementValueIfNotNull("width", width)
                 .SetElementValueIfNotNull("height", height)
                 .SetElementValueIfNotNull("color", color)
                 .SetElementValueIfNotNull("comment", comment);
         }
         xml.Save(fileName);
     }
 }

 /// <summary>
 /// XElementに便利な機能を追加する拡張クラス
 /// </summary>
 [DebuggerStepThrough]
 static class XElementExtension {
     /// <summary>
     /// 指定した値がnullでない場合のみ要素の値として設定します。
     /// </summary>
     /// <param name="ele">XML要素</param>
     /// <param name="name">要素名</param>
     /// <param name="value"></param>
     public static XElement SetElementValueIfNotNull(this XElement ele, XName name, object value) {
         if(value != null) ele.SetElementValue(name, value);

         return ele;
     }
 }
}

解説

引数の数が多すぎるけど、QueryStringとかから直接取るよりはマシと思っておく。

foreach(var item in result) {
item.SetElementValueIfNotNull("left", left)
    .SetElementValueIfNotNull("top", top)
    .SetElementValueIfNotNull("width", width)
    .SetElementValueIfNotNull("height", height)
    .SetElementValueIfNotNull("color", color)
    .SetElementValueIfNotNull("comment", comment);
}

XLinqで引っ張ってきたXML要素に対して値を設定している。値がnullだった場合は変更されていないと判断してXML要素に値を設定していない。

この部分の条件分岐を書くのが面倒だったのでXElementにSetElementValueIfNotNullというメソッドを拡張メソッドで追加した。メソッドチェーンしているのはお遊び。

次はSilverlight側の処理になるわけだけど、Silverlight側では付箋紙の情報が変更されたらこのURLにリクエストを送ることになる。

でも、付箋紙の情報といっても色々な情報があって、それぞれが違う方法で変更される。

具体的には、

  • 付箋紙がドラッグされた時
  • 付箋紙がサイズ変更された時
  • 付箋紙のコメントが変更された時

がある。

なので、それぞれの変更を区別して変更された情報だけをこのURLに送るようにしたい。

付箋紙がドラッグされた時

まず、付箋紙がドラッグされた時に対応する。

ItemControlクラスに以下のイベントを定義する。

Controls/ItemControl.xaml.cs
/// <summary>
/// 付箋紙がドラッグされた時に呼び出されます。
/// </summary>
public event RoutedEventHandler ItemDragged;
/// <summary>
/// ItemDraggedイベントを呼び出します。
/// </summary>
/// <param name="e">イベント引数</param>
protected virtual void OnItemDragged(RoutedEventArgs e) {
  if(ItemDragged != null) ItemDragged(this, e);
}

このイベントを付箋紙がドラッグされた後に呼び出されるStopDraggingメソッドで呼び出すように変更する。

Controls/ItemControl.xaml.cs
/// <summary>
/// Dragを終了します。
/// </summary>
private void StopDragging() {
  if(dragStart) {
      dragStart = false;

      OnItemDragged(new RoutedEventArgs {
          Source = this
      });
  }
}

あとはメイン画面のほうでこのイベントに対応する必要があるので、まずCreateItemControlメソッドを以下のように変更する。

Page.xaml.cs
/// <summary>
/// 指定した付箋紙情報から付箋紙コントロールを生成します。
/// </summary>
/// <param name="item">付箋紙情報</param>
/// <returns>付箋紙コントロール</returns>
private ItemControl CreateItemControl(Item item) {
  var itemCtrl = new ItemControl {
      DataContext = item
  };
  itemCtrl.ItemDragged += itemCtrl_ItemDragged;
  itemCtrl.ItemDeleting += itemCtrl_ItemDeleting;

  return itemCtrl;
}

ItemDraggedイベントにイベントハンドラを追加している。

イベントハンドラの処理

Page.xaml.cs
private void itemCtrl_ItemDragged(object sender, RoutedEventArgs e) {
  var item = (Item)((FrameworkElement)sender).DataContext;

  SetItem(item.Id, new Dictionary<string, object>() {
      { "left", item.Left },
      { "top", item.Top }
  });
}

/// <summary>
/// 指定したidの付箋紙の情報を設定します。
/// </summary>
/// <param name="id">付箋紙のid</param>
/// <param name="values">キーと値</param>
private void SetItem(string id, Dictionary<string, object> values) {
  var queryParams = string.Join("&",
      values.Select(p => string.Format("{0}={1}", p.Key, p.Value)).ToArray()
  );
  var webClient = new WebClient();
  webClient.DownloadStringCompleted += (s, e) => {
      if(e.Error != null) {
          HtmlPage.Window.Alert("付箋紙の変更に失敗しました。");
      }
  };
  webClient.DownloadStringAsync(new Uri(
      string.Format(rootUrl + "Item/Set.aspx?id={0}&{1}", id, queryParams)
  ));
}

SetItemメソッドで付箋紙の情報を設定している。メソッドの引数が多くなるのでディクショナリでまとめることにした。

これでビルドして実行すると、付箋紙を移動して画面を読み込み直しても付箋紙の位置が記憶されているのが確認できるはす。

付箋紙がサイズ変更された時

次は付箋紙のサイズが変更された時。

ItemControlクラスに以下のイベントを定義する。

Controls/ItemControl.xaml.cs
/// <summary>
/// 付箋紙がサイズ変更された時に呼び出されます。
/// </summary>
public event RoutedEventHandler ItemResized;
/// <summary>
/// ItemResizedイベントを呼び出します。
/// </summary>
/// <param name="e">イベント引数</param>
protected virtual void OnItemResized(RoutedEventArgs e) {
  if(ItemResized != null) ItemResized(this, e);
}

このイベントを付箋紙がサイズ変更された後に呼び出されるStopResizingメソッドで呼び出すようにする。

Controls/ItemControl.xaml.cs
/// <summary>
/// サイズ変更を終了します。
/// </summary>
private void StopResizing() {
  if(resizeStart) {
      resizeStart = false;

      OnItemResized(new RoutedEventArgs {
          Source = this
      });
  }
}

ドラッグされた時と同じ様にCreateItemControlメソッドを変更する。

Page.xaml.cs
/// <summary>
/// 指定した付箋紙情報から付箋紙コントロールを生成します。
/// </summary>
/// <param name="item">付箋紙情報</param>
/// <returns>付箋紙コントロール</returns>
private ItemControl CreateItemControl(Item item) {
  var itemCtrl = new ItemControl {
      DataContext = item
  };
  itemCtrl.ItemDragged += itemCtrl_ItemDragged;
  itemCtrl.ItemResized += itemCtrl_ItemResized;
  itemCtrl.ItemDeleting += itemCtrl_ItemDeleting;

  return itemCtrl;
}

ItemResizedイベントハンドラの処理。

Controls/ItemControl.xaml.cs
private void itemCtrl_ItemResized(object sender, RoutedEventArgs e) {
  var item = (Item)((FrameworkElement)sender).DataContext;

  SetItem(item.Id, new Dictionary<string, object>() {
      { "width", item.Width },
      { "height", item.Height }
  });
}

これでサイズ変更も記憶されるようになった。

付箋紙のコメントが変更された時

残りはコメントが変更された時。

まずは付箋紙のコメントを変更できるようにする。

Controls/ItemControl.xaml

<UserControl x:Class="Fusen.Controls.ItemControl"
  xmlns="http://schemas.microsoft.com/client/2007"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  Width="{Binding Width, Mode=TwoWay}" Height="{Binding Height, Mode=TwoWay}"
  Canvas.Left="{Binding Left, Mode=TwoWay}" Canvas.Top="{Binding Top, Mode=TwoWay}"
  Loaded="UserControl_Loaded" SizeChanged="UserControl_SizeChanged">
    
  <UserControl.Resources>
      <Style x:Key="commandButton" TargetType="Button">
          <Setter Property="Template">
              <Setter.Value>
                  <ControlTemplate TargetType="Button">
                      <ContentPresenter Content="{TemplateBinding Content}"
                                        FontSize="{TemplateBinding FontSize}"
                                        Cursor="Hand"
                                        HorizontalAlignment="{TemplateBinding HorizontalAlignment}"
                                        VerticalAlignment="{TemplateBinding VerticalAlignment}"
                                        />
                  </ControlTemplate>
              </Setter.Value>
          </Setter>
      </Style>
  </UserControl.Resources>
    
  <Grid>
      <Polygon x:Name="resizeArea" Fill="{Binding Brush}" Opacity="0.6" Stroke="Black" Points="0,0 0,150, 175,150, 200, 125, 200,0" />
      <Polygon x:Name="resizeGrip" Fill="White" Stroke="Silver" Points="175,150, 175,125, 200,125" Cursor="Hand"
               MouseLeftButtonDown="resizeGrip_MouseLeftButtonDown" MouseLeftButtonUp="resizeGrip_MouseLeftButtonUp" />

      <Border x:Name="dragGrip" BorderBrush="Black" Width="20" Height="20" CornerRadius="10" Cursor="Hand"
              HorizontalAlignment="Left" VerticalAlignment="Top" Margin="6,6,0,0"
              MouseLeftButtonDown="dragGrip_MouseLeftButtonDown" MouseLeftButtonUp="dragGrip_MouseLeftButtonUp">
          <Border.Background>
              <RadialGradientBrush>
                  <GradientStop Color="White" Offset="0" />
                  <GradientStop Color="Silver" Offset="1" />
              </RadialGradientBrush>
          </Border.Background>
      </Border>
        
      <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Top">
          <Grid x:Name="editArea">
              <Button x:Name="editButton" Content="edit" Margin="0,4,6,0"
                  Click="editButton_Click" Style="{StaticResource commandButton}" />
          </Grid>
          <Grid x:Name="updateArea" Visibility="Collapsed">
              <Button x:Name="updateButton" Content="update" Margin="0,4,6,0"
                  Click="updateButton_Click" Style="{StaticResource commandButton}" />
          </Grid>
            
          <Button x:Name="deleteButton" Content="x" Margin="0,0,4,0"
                  Click="deleteButton_Click"  Style="{StaticResource commandButton}" />
      </StackPanel>
        
      <TextBlock x:Name="commentText" Text="{Binding Comment}" TextWrapping="Wrap"
                 FontSize="12" FontFamily="MS UI Gothic" Margin="5,35,5,30" />
      <TextBox x:Name="commentInput" Text="{Binding Comment, Mode=TwoWay}" AcceptsReturn="True" Visibility="Collapsed"
               FontSize="12" FontFamily="MS UI Gothic" Margin="5,35,5,30" />
  </Grid>
    
</UserControl>

解説
<Grid x:Name="editArea">
 <Button x:Name="editButton" Content="edit" Margin="0,4,6,0"
     Click="editButton_Click" Style="{StaticResource commandButton}" />
</Grid>
<Grid x:Name="updateArea" Visibility="Collapsed">
 <Button x:Name="updateButton" Content="update" Margin="0,4,6,0"
     Click="updateButton_Click" Style="{StaticResource commandButton}" />
</Grid>

「edit」ボタンをクリックした時に替わりに表示する「update」をボタンを追加した。それぞれGridで囲んでいるけど、その理由は後述する。

この二つのボタンのイベントハンドラの処理。

Controls/ItemControl.xaml.cs
private void editButton_Click(object sender, RoutedEventArgs e) {
  editArea.Visibility = Visibility.Collapsed;
  updateArea.Visibility = Visibility.Visible;

  commentInput.Visibility = Visibility.Visible;
}

private void updateButton_Click(object sender, RoutedEventArgs e) {
  commentText.Text = commentInput.Text;
  commentInput.Visibility = Visibility.Collapsed;

  updateArea.Visibility = Visibility.Collapsed;
  editArea.Visibility = Visibility.Visible;

  OnItemChanged(new RoutedEventArgs {
      Source = this
  });
}

それぞれのイベントハンドラでボタンの表示状態の切り替えを行っている。初めはボタン自身を非表示にしていたけど、イベントハンドラで自分自身を非表示にすると例外が出る(仕様か?)みたいなので、それぞれのボタンをGridの中に入れてそのGrid非表示にするようにした。

「update」ボタンではOnItemChangedメソッドを呼び出している。このメソッドの実装は以下。

Controls/ItemControl.xaml.cs
/// <summary>
/// 付箋紙の情報が変更された時に呼び出されます。
/// </summary>
public event RoutedEventHandler ItemChanged;
/// <summary>
/// ItemChangedイベントを呼び出します。
/// </summary>
/// <param name="e">イベント引数</param>
protected virtual void OnItemChanged(RoutedEventArgs e) {
   if(ItemChanged != null) ItemChanged(this, e);
}
スクリーンショット

あとはPage.xaml側でこのイベントに対応するようにする。

Page.xaml.cs
/// <summary>
/// 指定した付箋紙情報から付箋紙コントロールを生成します。
/// </summary>
/// <param name="item">付箋紙情報</param>
/// <returns>付箋紙コントロール</returns>
private ItemControl CreateItemControl(Item item) {
   var itemCtrl = new ItemControl {
       DataContext = item
   };
   itemCtrl.ItemDragged += itemCtrl_ItemDragged;
   itemCtrl.ItemResized += itemCtrl_ItemResized;
   itemCtrl.ItemChanged += itemCtrl_ItemChanged;
   itemCtrl.ItemDeleting += itemCtrl_ItemDeleting;

   return itemCtrl;
}

private void itemCtrl_ItemChanged(object sender, RoutedEventArgs e) {
   var item = (Item)((FrameworkElement)sender).DataContext;

   SetItem(item.Id, new Dictionary<string, object>() {
       { "comment", item.Comment }
   });
}

コメントが変更された時にSetItemメソッドでコメントの情報を変更しに行っている。

でも、このコードはうまく動作しない。何故かと言えばコメントには日本語が入力できるのにURLエンコードしていないことはもちろんだけど、Silverlight側でURLエンコードした値はASP.NET側では文字化けしてしまうからだ(バグ?)。それ以前にGETリクエストで大量のデータを送るのはよろしくない。

これを回避するにはGETじゃなくてPOSTでリクエストしてPOSTデータとして値を送ってやればいいんだけど、現状SilverlightにはPOSTでリクエストする手段が用意されていない。

じゃあどうするかというとJavaScriptを使う。相互運用性が高いSilverlightならではのやり方。

JavaScriptと言っても生のJavaScriptを使うのはしんどいので、jQueryを使う。ここではjQuerySilverlightから簡単に使うために作った拙作のライブラリ「Silverlight.JQuery」を使う。ダウンロードは以下から。
OneDrive

まず、ダウンロードしたzipを解凍してできた「Silverlight.JQuery.dll」をSilverlight側のプロジェクトで参照設定しておく。

そして最新のjQueryをダウンロードしてきて、「Fusen.Web」プロジェクトに「js」というフォルダを作ってその中にスクリプトを入れておく。
Download jQuery | jQuery

あとはSilverlightをホストする画面を以下のように変更する。

Views/Home/Index.aspx

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Index.aspx.cs" Inherits="Fusen.Web.Views.Home.Index" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<html xmlns="http://www.w3.org/1999/xhtml" style="height: 100%;">
<head runat="server">
   <title>付箋紙 for Silverlight</title>
   <script type="text/javascript" src="js/jquery-1.2.3.pack.js"></script>
   <script type="text/javascript">
       function onPluginLoaded(sender) {
           ajaxCtl = sender.get_element();
       }
   </script>
</head>
<body style="height: 100%;">
   <form runat="server" style="height: 100%;">
       <asp:ScriptManager ID="ScriptManager1" runat="server"></asp:ScriptManager>
       <div style="height: 100%;">
           <asp:Silverlight ID="Silverlight1" runat="server"
               Height="100%" Width="100%" Source="~/ClientBin/Fusen.xap" Version="2.0"
               OnPluginLoaded="onPluginLoaded" />
       </div>
   </form>
</body>
</html>

jQueryを参照するようにして、Silverlightコントロールに「OnPluginLoaded」というイベントハンドラを追加している。

あとは、付箋紙の情報を変更する時にWebClientクラスを使っていたところをjQueryでやるように変更する。

Page.xaml.cs
/// <summary>
/// 指定したidの付箋紙の情報を設定します。
/// </summary>
/// <param name="id">付箋紙のid</param>
/// <param name="values">キーと値</param>
private void SetItem(string id, Dictionary<string, object> values) {
   values.Add("id", id);

   JQuery.Post((rootUrl + "Item/Set.aspx"), values, null);
}

JQuery.Postメソッドを使うと指定したURLに対してPOSTリクエストを送ることができる。

これで文字化けが解消できた。

ちなみにASP.NET MVC側はGETからPOSTに変わっても何もコードは変更しなくていい。アクションメソッドの引数にマップされる値はGETメソッドならクエリパラメータからPOSTメソッドならPOSTデータからにと自動的に判断してくれるからだた。

これでビルドして実行すると、コメントの値を変更できることが確認できるはず。

これで完成。まだ色の変更機能が残っているけど、ここまで読んだ人なら自力でできると思うので挑戦してください。

さぁ次のネタに移ろうか。

ソース

OneDrive